Files
iOS/Sources/Shared/MagicItem/MagicItem.swift
2026-03-30 22:42:09 +02:00

441 lines
15 KiB
Swift

import Foundation
import GRDB
import HAKit
import PromiseKit
/// Object that represents iOS item that can be displayed in Watch, Widgets, CarPlay and perform different action types
public struct MagicItem: Codable, Equatable, Hashable {
/// Identity-based equality for use in sets/dictionaries and caching.
/// Compares only stable identity fields, not mutable content.
public static func == (lhs: MagicItem, rhs: MagicItem) -> Bool {
lhs.id == rhs.id
&& lhs.serverId == rhs.serverId
&& lhs.type == rhs.type
}
/// Identity-based hashing consistent with `==`.
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(serverId)
hasher.combine(type)
}
/// Content-based equality for UI/change detection.
/// Unlike `==`, this includes mutable fields.
public func contentEquals(_ other: MagicItem) -> Bool {
id == other.id
&& serverId == other.serverId
&& type == other.type
&& customization == other.customization
&& action == other.action
&& displayText == other.displayText
&& items == other.items
}
/// Id match it's type Id, e.g. "script.open_gate"
public let id: String
public var serverId: String
public let type: ItemType
public var customization: Customization?
public var action: ItemAction?
public var displayText: String?
public var items: [MagicItem]? /// Only for folder type, represents items inside the folder
/// Server unique ID - e.g. "EB1364-script.open_gate"
public var serverUniqueId: String {
"\(serverId)-\(id)"
}
/// A hash value that includes mutable content fields, for use as a SwiftUI animation/change detection value.
public var contentHash: Int {
var hasher = Hasher()
hasher.combine(id)
hasher.combine(serverId)
hasher.combine(type)
hasher.combine(customization)
hasher.combine(action)
hasher.combine(displayText)
hasher.combine(items?.map(\.contentHash))
return hasher.finalize()
}
/// Domain retrieved from id when item is entity else nil
public var domain: Domain? {
if let domainString = id.split(separator: ".").first, let domain = Domain(rawValue: String(domainString)) {
return domain
} else {
return nil
}
}
public init(
id: String,
serverId: String,
type: ItemType,
customization: Customization? = .init(),
action: ItemAction? = .default,
displayText: String? = nil,
items: [MagicItem]? = nil
) {
self.id = id
self.serverId = serverId
self.type = type
self.customization = customization
self.action = action
self.displayText = displayText
self.items = items
}
public enum ItemType: String, Codable {
/// aka iOS legacy Action
case action
case script
case scene
case entity
case folder
}
public struct Customization: Codable, Equatable, Hashable {
public var iconColor: String?
public var textColor: String?
public var backgroundColor: String?
/// If true, execution will request confirmation before running
public var requiresConfirmation: Bool
/// Override icon, MaterialDesignIcons name
public var icon: String?
/// True only when the user explicitly picked a custom icon via the icon picker
public var iconIsCustomized: Bool?
public var useCustomColors: Bool {
textColor != nil || backgroundColor != nil
}
public init(
iconColor: String? = nil,
textColor: String? = nil,
backgroundColor: String? = nil,
requiresConfirmation: Bool = false,
icon: String? = nil,
iconIsCustomized: Bool = false
) {
self.iconColor = iconColor
self.textColor = textColor
self.backgroundColor = backgroundColor
self.requiresConfirmation = requiresConfirmation
self.icon = icon
self.iconIsCustomized = iconIsCustomized
}
}
public struct Info: WatchCodable, Equatable {
/// Server unique ID - "\(serverId)-(entityId)"
public let id: String
public let name: String
public let iconName: String
public let customization: Customization?
public init(id: String, name: String, iconName: String, customization: Customization? = nil) {
self.id = id
self.name = name
self.iconName = iconName
self.customization = customization
}
}
/// Icon for given magic item type
public func icon(info: Info) -> MaterialDesignIcons {
var icon: MaterialDesignIcons
if let icon = customization?.icon {
return MaterialDesignIcons(named: icon, fallback: .dotsGridIcon)
} else {
switch type {
case .action, .scene:
icon = MaterialDesignIcons(named: info.iconName, fallback: .scriptTextOutlineIcon)
case .script, .entity:
icon = MaterialDesignIcons(
serversideValueNamed: info.iconName,
fallback: .dotsGridIcon
)
case .folder:
icon = .folderIcon
}
}
return icon
}
/// Name to be visible when rendegin item, priority: displayText -> info.name
public func name(info: Info) -> String {
displayText ?? info.name
}
public var widgetInteractionType: WidgetInteractionType {
let magicItem = self
guard let domain = magicItem.domain else { return .appIntent(.refresh) }
var interactionType: WidgetInteractionType = .appIntent(.refresh)
if let magicItemAction = magicItem.action, magicItemAction != .default {
switch magicItemAction {
case .default:
// This block of code should not be reached, default should not be handled here
// Returning something to avoid compiler error
interactionType = .appIntent(.refresh)
case .moreInfoDialog:
interactionType = navigateIntent(url: AppConstants.openEntityDeeplinkURL(
entityId: magicItem.id,
serverId: magicItem.serverId
))
case .nothing:
interactionType = .appIntent(.refresh)
case let .navigate(path):
interactionType = navigateIntent(path: path)
case let .runScript(serverId, scriptId):
interactionType = .appIntent(.activate(
entityId: scriptId,
domain: Domain.script.rawValue,
serverId: serverId
))
case let .assist(serverId, pipelineId, startListening):
interactionType = assistIntent(
serverId: serverId,
pipelineId: pipelineId,
startListening: startListening
)
}
} else {
switch domain {
case .button, .inputButton:
interactionType = .appIntent(.press(
entityId: magicItem.id,
domain: domain.rawValue,
serverId: magicItem.serverId
))
case .cover, .inputBoolean, .light, .switch:
interactionType = .appIntent(.toggle(
entityId: magicItem.id,
domain: domain.rawValue,
serverId: magicItem.serverId
))
case .lock:
interactionType = navigateIntent(url: AppConstants.openEntityDeeplinkURL(
entityId: magicItem.id,
serverId: magicItem.serverId
))
case .climate:
interactionType = navigateIntent(url: AppConstants.openEntityDeeplinkURL(
entityId: magicItem.id,
serverId: magicItem.serverId
))
case .scene, .script:
interactionType = .appIntent(.activate(
entityId: magicItem.id,
domain: domain.rawValue,
serverId: magicItem.serverId
))
default:
interactionType = navigateIntent(url: AppConstants.openEntityDeeplinkURL(
entityId: magicItem.id,
serverId: magicItem.serverId
))
}
}
return interactionType
}
private func navigateIntent(path: String) -> WidgetInteractionType {
let magicItem = self
var path = path
if path.hasPrefix("/") {
path.removeFirst()
}
if let url = AppConstants.navigateDeeplinkURL(
path: path,
serverId: magicItem.serverId,
avoidUnnecessaryReload: true
) {
return .widgetURL(url)
} else {
return .appIntent(.refresh)
}
}
private func navigateIntent(url: URL?) -> WidgetInteractionType {
guard let url else {
return .appIntent(.refresh)
}
return .widgetURL(url)
}
private func assistIntent(serverId: String, pipelineId: String, startListening: Bool) -> WidgetInteractionType {
if let url = AppConstants.assistDeeplinkURL(
serverId: serverId,
pipelineId: pipelineId,
startListening: startListening
) {
return .widgetURL(url)
} else {
return .appIntent(.refresh)
}
}
}
public enum MagicItemError: Error {
case unknownDomain
}
public enum ItemAction: Codable, CaseIterable, Equatable, Hashable {
public static var allCases: [ItemAction] = [
.default,
.moreInfoDialog,
.navigate(""),
.runScript("", ""),
.assist("", "", false),
.nothing,
]
case `default`
case moreInfoDialog
case navigate(_ navigationPath: String)
case runScript(_ serverId: String, _ scriptId: String)
case assist(_ serverId: String, _ pipelineId: String, _ startListening: Bool)
case nothing
public var id: String {
switch self {
case .default:
return "default"
case .moreInfoDialog:
return "moreInfoDialog"
case .navigate:
return "navigate"
case .runScript:
return "runScript"
case .assist:
return "assist"
case .nothing:
return "nothing"
}
}
public var name: String {
switch self {
case .default:
return L10n.Widgets.Action.Name.default
case .moreInfoDialog:
return L10n.Widgets.Action.Name.moreInfoDialog
case .navigate:
return L10n.Widgets.Action.Name.navigate
case .runScript:
return L10n.Widgets.Action.Name.runScript
case .assist:
return L10n.Widgets.Action.Name.assist
case .nothing:
return L10n.Widgets.Action.Name.nothing
}
}
}
public extension MagicItem {
// currentItemState is used only for lock domain since it can't be toggled
func execute(
on server: Server,
source: AppTriggerSource,
currentItemState: String = "",
completion: @escaping (Bool) -> Void
) {
do {
if let request: Promise<Void> = try {
switch type {
case .script:
let domain = Domain.script.rawValue
let service = id.replacingOccurrences(of: "\(domain).", with: "")
return Current.api(for: server)?.CallService(
domain: domain,
service: service,
serviceData: [:],
triggerSource: source,
shouldLog: true
)
case .action:
return Current.api(for: server)?
.HandleAction(actionID: id, source: source)
case .scene:
let domain = Domain.scene.rawValue
return Current.api(for: server)?.CallService(
domain: domain,
service: Service.turnOn.rawValue,
serviceData: [
"entity_id": id,
],
triggerSource: source,
shouldLog: true
)
case .entity:
guard let domain else {
throw MagicItemError.unknownDomain
}
return executeActionForDomainType(
server: server,
domain: domain,
entityId: id,
state: currentItemState
)
case .folder:
// Folders don't execute actions
return nil
}
}() {
request.pipe(to: { result in
switch result {
case .fulfilled:
Current.Log.verbose("Success executing magic item \(id)")
completion(true)
case let .rejected(error):
Current.Log.error("Error while executing magic item \(id): \(error.localizedDescription)")
completion(false)
}
})
} else {
Current.Log.error("No request available while executing magic item \(id)")
}
} catch {
Current.Log.error("Error while executing magic item (2): \(error.localizedDescription)")
completion(false)
}
}
private func executeActionForDomainType(
server: Server,
domain: Domain,
entityId: String,
state: String
) -> Promise<Void> {
var request: HATypedRequest<HAResponseVoid>?
// Lock requires state-aware action
if domain == .lock {
guard let state = Domain.State(rawValue: state) else { return .value }
switch state {
case .unlocking, .unlocked, .opening:
request = .lockLock(entityId: entityId)
case .locked, .locking:
request = .unlockLock(entityId: entityId)
default:
break
}
} else {
// Use domain's main action for all other domains
request = .executeMainAction(domain: domain, entityId: entityId)
}
if let request, let connection = Current.api(for: server)?.connection {
return connection.send(request).promise
.map { _ in () }
} else {
return .value
}
}
}