From 4669e1711c5f7a2b03c62689555a9c802160b889 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 9 Mar 2023 18:03:20 +0000 Subject: [PATCH 01/41] CarPlay implementation --- .../Entitlements/App-ios.entitlements | 2 + HomeAssistant.xcodeproj/project.pbxproj | 44 +++ Sources/App/AppDelegate.swift | 14 +- .../Resources/en.lproj/Localizable.strings | 42 ++- Sources/App/Scenes/CarPlaySceneDelegate.swift | 268 ++++++++++++++++++ .../Extensions/HAEntityExtension.swift | 219 ++++++++++++++ .../Extensions/ServerManagerExtension.swift | 32 +++ .../Templates/DomainsListTemplate.swift | 103 +++++++ .../Templates/EntitiesGridTemplate.swift | 130 +++++++++ 9 files changed, 849 insertions(+), 5 deletions(-) create mode 100644 Sources/App/Scenes/CarPlaySceneDelegate.swift create mode 100644 Sources/Vehicle/Extensions/HAEntityExtension.swift create mode 100644 Sources/Vehicle/Extensions/ServerManagerExtension.swift create mode 100644 Sources/Vehicle/Templates/DomainsListTemplate.swift create mode 100644 Sources/Vehicle/Templates/EntitiesGridTemplate.swift diff --git a/Configuration/Entitlements/App-ios.entitlements b/Configuration/Entitlements/App-ios.entitlements index 153010f74..ed55c7a14 100644 --- a/Configuration/Entitlements/App-ios.entitlements +++ b/Configuration/Entitlements/App-ios.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.carplay-driving-task + aps-environment development com.apple.developer.associated-domains diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 8302b9d4b..e80243929 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -905,6 +905,11 @@ D0FF79D220D87D200034574D /* ClientEventTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FF79D120D87D200034574D /* ClientEventTableViewController.swift */; }; D0FF79D520D87DB10034574D /* ClientEvents.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D0FF79D420D87DB10034574D /* ClientEvents.storyboard */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; + FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; + FD3BC66729BA003B00B19FBE /* HAEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */; }; + FD3BC66929BA008900B19FBE /* ServerManagerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66829BA008900B19FBE /* ServerManagerExtension.swift */; }; + FD3BC66C29BA00D600B19FBE /* EntitiesGridTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* EntitiesGridTemplate.swift */; }; + FD3BC66E29BA010A00B19FBE /* DomainsListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */; }; FD5FEB304713F1E6BFE498DC /* Pods_iOS_Extensions_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE950A9D74B3E7FF5665CB38 /* Pods_iOS_Extensions_NotificationService.framework */; }; /* End PBXBuildFile section */ @@ -2149,6 +2154,11 @@ F3A0FB3BD04C582E655168D0 /* Pods-Tests-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.release.xcconfig"; sourceTree = ""; }; F3E55AA06795782F04D0B261 /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; }; F534C18A6FD4884F258341C9 /* Pods-iOS-Shared-iOS.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.beta.xcconfig"; sourceTree = ""; }; + FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; + FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAEntityExtension.swift; sourceTree = ""; }; + FD3BC66829BA008900B19FBE /* ServerManagerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManagerExtension.swift; sourceTree = ""; }; + FD3BC66B29BA00D600B19FBE /* EntitiesGridTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitiesGridTemplate.swift; sourceTree = ""; }; + FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsListTemplate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2347,6 +2357,7 @@ 1115018C2528411200DCFA94 /* Sources */ = { isa = PBXGroup; children = ( + FD3BC66429BA000A00B19FBE /* Vehicle */, B657A8E81CA646EB00121384 /* App */, 111501A72528412C00DCFA94 /* Extensions */, 11DE9D8425B6103C0081C0ED /* Launcher */, @@ -2951,6 +2962,7 @@ 11EFCDDB24F6065F00314D85 /* AboutSceneDelegate.swift */, 11EFCDDF24F60E5900314D85 /* BasicSceneDelegate.swift */, 118261F424F8C7C1000795C6 /* SceneManager.swift */, + FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */, ); path = Scenes; sourceTree = ""; @@ -4016,6 +4028,33 @@ path = Common; sourceTree = ""; }; + FD3BC66429BA000A00B19FBE /* Vehicle */ = { + isa = PBXGroup; + children = ( + FD3BC66A29BA00B100B19FBE /* Templates */, + FD3BC66529BA001A00B19FBE /* Extensions */, + ); + path = Vehicle; + sourceTree = ""; + }; + FD3BC66529BA001A00B19FBE /* Extensions */ = { + isa = PBXGroup; + children = ( + FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */, + FD3BC66829BA008900B19FBE /* ServerManagerExtension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + FD3BC66A29BA00B100B19FBE /* Templates */ = { + isa = PBXGroup; + children = ( + FD3BC66B29BA00D600B19FBE /* EntitiesGridTemplate.swift */, + FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */, + ); + path = Templates; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -5653,13 +5692,16 @@ 1187DE4224D77CCC00F0A6A6 /* NFCTagViewController.swift in Sources */, D0EEF324214DF2B700D1D360 /* Utils.swift in Sources */, 1101D7F92621479200AAE617 /* SettingsButtonRow.swift in Sources */, + FD3BC66C29BA00D600B19FBE /* EntitiesGridTemplate.swift in Sources */, B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */, B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */, 119D765F2492F8FA00183C5F /* UIApplication+BackgroundTask.swift in Sources */, 11195F6F267EFC8E003DF674 /* NotificationManagerLocalPushInterfaceDirect.swift in Sources */, + FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */, 11C4629424B189B100031902 /* NotificationRateLimitsAPI.swift in Sources */, 1161C01B24D7634300A0E3C4 /* NFCListViewController.swift in Sources */, 11A71C6B24A463FC00D9565F /* ZoneManagerState.swift in Sources */, + FD3BC66E29BA010A00B19FBE /* DomainsListTemplate.swift in Sources */, 1185DFAF271FF53800ED7D9A /* OnboardingAuthStepRegister.swift in Sources */, 11F20BC5274B06C100DFB163 /* ServerSelectRow.swift in Sources */, 1130F532253A1E7400F371BE /* ComplicationListViewController.swift in Sources */, @@ -5686,6 +5728,7 @@ 11A71C7124A4648000D9565F /* ZoneManagerEquatableRegion.swift in Sources */, 11E99A5027156854003C8A65 /* OnboardingTerminalViewController.swift in Sources */, 1101568424D770B2009424C9 /* NFCWriter.swift in Sources */, + FD3BC66929BA008900B19FBE /* ServerManagerExtension.swift in Sources */, 11E7C4B02702E03000667342 /* WidgetOpenPageIntent+Observation.swift in Sources */, 1187DE4624D7E1BD00F0A6A6 /* SimulatorNFCManager.swift in Sources */, 1185DF96271FBB9800ED7D9A /* OnboardingAuthLogin.swift in Sources */, @@ -5744,6 +5787,7 @@ 42F5CABC2B10AE1A00409816 /* ServerFixture.swift in Sources */, 11B1FFC524CCD72F00F9BCB2 /* VoiceShortcutRow.swift in Sources */, 1168BF33271809C600DD4D15 /* OnboardingAuthError.swift in Sources */, + FD3BC66729BA003B00B19FBE /* HAEntityExtension.swift in Sources */, B661FB6F226BCCAD00E541DD /* ConnectionSettingsViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index a084580c0..ee6f6f17a 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -184,10 +184,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions ) -> UISceneConfiguration { - let activity = options.userActivities - .compactMap { SceneActivity(activityIdentifier: $0.activityType) } - .first ?? .webView - return activity.configuration + if #available(iOS 16.0, *), connectingSceneSession.role == UISceneSession.Role.carTemplateApplication { + let scene = UISceneConfiguration(name: "CarPlay", sessionRole: connectingSceneSession.role) + scene.delegateClass = CarPlayDelegate.self + return scene + } else { + let activity = options.userActivities + .compactMap { SceneActivity(activityIdentifier: $0.activityType) } + .first ?? .webView + return activity.configuration + } } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index c064bcb09..e9fff6a55 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -784,4 +784,44 @@ Home Assistant is free and open source home automation software with a focus on "widgets.open_page.description" = "Open a frontend page in Home Assistant."; "widgets.open_page.not_configured" = "No Pages Available"; "widgets.open_page.title" = "Open Page"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; +"carplay.labels.buttons" = "Buttons"; +"carplay.labels.covers" = "Covers"; +"carplay.labels.input_booleans" = "Input Booleans"; +"carplay.labels.input_buttons" = "Input Buttons"; +"carplay.labels.lights" = "Lights"; +"carplay.labels.locks" = "Locks"; +"carplay.labels.scenes" = "Scenes"; +"carplay.labels.scripts" = "Scripts"; +"carplay.labels.switches" = "Switches"; +"carplay.labels.servers" = "Servers"; +"carplay.labels.empty_domain_list" = "No domains available"; +"carplay.labels.no_servers_available" = "No servers available. Add a server at home assistant Companion App."; +"carplay.labels.already_added_server" = "Already added"; +"state.auto" = "Auto"; +"state.cleaning" = "Cleaning"; +"state.closed" = "Closed"; +"state.closing" = "Closing"; +"state.cool" = "Cool"; +"state.docked" = "Docked"; +"state.dry" = "Dry"; +"state.error" = "Error"; +"state.fan_only" = "Fan Only"; +"state.heat_cool" = "Heat Cool"; +"state.heat" = "Heat"; +"state.idle" = "Idle"; +"state.jammed" = "Jammed"; +"state.locked" = "Locked"; +"state.locking"= "Locking"; +"state.off" = "Off"; +"state.on" = "On"; +"state.open" = "Open"; +"state.opening" = "Opening"; +"state.paused" = "Paused"; +"state.returning" = "Returning"; +"state.unavailable" = "Unavailable"; +"state.unknown" = "Unknown"; +"state.unlocked" = "Unlocked"; +"state.unlocking" = "Unlocking"; +"state.recording" = "Recording"; +"state.streaming" = "Streaming"; diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift new file mode 100644 index 000000000..97f2b0d1a --- /dev/null +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -0,0 +1,268 @@ +// +// CarPlayDelegate.swift +// App +// +// Created by Luis Lopes on 15/02/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import CarPlay +import Shared +import HAKit +import PromiseKit +import Communicator + +public protocol EntitiesStateSubscription { + func subscribe() + func unsubscribe() +} + +@available(iOS 16.0, *) +class CarPlayDelegate : UIResponder { + public static let SUPPORTED_DOMAINS_WITH_STRING = [ + "button" : L10n.Carplay.Labels.buttons, + "cover" : L10n.Carplay.Labels.covers, + "input_boolean" : L10n.Carplay.Labels.inputBooleans, + "input_button" : L10n.Carplay.Labels.inputButtons, + "light" : L10n.Carplay.Labels.lights, + "lock" : L10n.Carplay.Labels.locks, + "scene" : L10n.Carplay.Labels.scenes, + "script" : L10n.Carplay.Labels.scripts, + "switch" : L10n.Carplay.Labels.switches + ] + + public let SUPPORTED_DOMAINS = SUPPORTED_DOMAINS_WITH_STRING.keys + + private var MAP_DOMAINS = [ + "device_tracker", + "person", + "sensor", + "zone" + ] + + private var interfaceController: CPInterfaceController? + private var filteredEntities: [HAEntity] = [] + private var entitiesGridTemplate : EntitiesGridTemplate? + private var domainsListTemplate : DomainsListTemplate? + private var entitiesStateSubscribeCancelable : HACancellable? + private var serverObserver : HACancellable? + private var serverId : Identifier? { + didSet { + loadEntities() + } + } + + let prefs = UserDefaults(suiteName: Constants.AppGroupID)! + + func loadEntities() { + self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + + guard let serverId = serverId, let server = Current.servers.server(for: serverId) else { + Current.Log.info("No server available to get entities") + filteredEntities.removeAll() + self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) + return + } + + guard let allServerEntities = Current.api(for: server).connection.caches.states.value?.all else { + Current.Log.info("No entities available from server \(server.info.name)") + filteredEntities.removeAll() + self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) + interfaceController?.setRootTemplate(self.domainsListTemplate!.getTemplate(), animated: false) + return + } + + filteredEntities = getFilteredAndSortEntities(entities: Array(allServerEntities)) + self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) + if let template = self.domainsListTemplate?.getTemplate() { + interfaceController?.setRootTemplate(template, animated: false) + } + } + + func getFilteredAndSortEntities(entities: [HAEntity]) -> [HAEntity] { + var tmpEntities : [HAEntity] = [] + + for entity in entities where SUPPORTED_DOMAINS.contains(entity.domain) { + tmpEntities.append(entity) + } + return tmpEntities.sorted(by: {$0.getFriendlyState() < $1.getFriendlyState()}) + } + + func setServer(server: Server) { + serverId = server.identifier + serverObserver = server.observe { [weak self] _ in + self?.connectionInfoDidChange() + } + + entitiesStateSubscribeCancelable?.cancel() + prefs.set(server.identifier.rawValue, forKey: "carPlay-server") + subscribeEntitiesUpdates(for: server) + } + + @objc private func connectionInfoDidChange() { + DispatchQueue.main.async { + self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + if self.serverId == nil { + ///No server is selected + guard let server = Current.servers.getServer() else { + Current.Log.info("No server connected") + return + } + self.setServer(server: server) + } + } + } + + func subscribeEntitiesUpdates(for server: Server) { + Current.Log.info("Subscribe for entities update at server \(server.info.name)") + entitiesStateSubscribeCancelable?.cancel() + entitiesStateSubscribeCancelable = Current.api(for: server).connection.caches.states.subscribe { [weak self] cancellable, cachedStates in + Current.Log.info("Received entities update of server \(server.info.name)") + guard let self = self else { + cancellable.cancel() + return + } + + self.loadEntities() + } + } + + //Templates + + func showNoServerAlert() { + guard self.interfaceController?.presentedTemplate == nil else { + return + } + + let loginAlertAction : CPAlertAction = CPAlertAction(title: L10n.Carplay.Labels.alreadyAddedServer, style: .default) { _ in + if !Current.servers.all.isEmpty { + self.interfaceController?.dismissTemplate(animated: true) + } + } + let alertTemplate = CPAlertTemplate(titleVariants: [L10n.Carplay.Labels.noServersAvailable], actions: [loginAlertAction]) + self.interfaceController?.presentTemplate(alertTemplate, animated: true) + } + + func setDomainListTemplate() { + domainsListTemplate = DomainsListTemplate(title: L10n.About.Logo.appTitle, entities: filteredEntities, ic : interfaceController!, + listItemHandler: {[weak self] domain, entities in + + guard let self = self, let server = Current.servers.getServer(id: self.serverId) else { + return + } + + let itemTitle = CarPlayDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain + self.entitiesGridTemplate = EntitiesGridTemplate(title: itemTitle, domain: domain, server: server, entities: entities) + self.interfaceController?.pushTemplate(self.entitiesGridTemplate!.getTemplate(), animated: true) + }, serverButtonHandler: { _ in + self.setServerListTemplate() + }) + + interfaceController?.setRootTemplate(domainsListTemplate!.getTemplate(), animated: true) + } + + func setServerListTemplate() { + var serverList : [CPListItem] = [] + for server in Current.servers.all { + let serverItem = CPListItem(text: server.info.name, detailText: "\(server.info.connection.activeURLType.description) - \(server.info.connection.activeURL().absoluteString)") + serverItem.handler = { [weak self] item, completion in + self?.setServer(server: server) + if let templates = self?.interfaceController?.templates, templates.count > 1 { + self?.interfaceController?.popTemplate(animated: true) + } + completion() + } + serverItem.accessoryType = self.serverId == server.identifier ? .cloud : .none + serverList.append(serverItem) + } + let section = CPListSection(items: serverList) + let serverListTemplate = CPListTemplate(title: L10n.Carplay.Labels.servers, sections: [section]) + self.interfaceController?.pushTemplate(serverListTemplate, animated: true) + } +} + +@available(iOS 16.0, *) +extension CarPlayDelegate : CPTemplateApplicationSceneDelegate { + func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) { + self.interfaceController = interfaceController + self.interfaceController?.delegate = self + + /// Observer for servers list changes + Current.servers.add(observer: self) + + setDomainListTemplate() + + if Current.servers.all.isEmpty { + showNoServerAlert() + } + + if Current.servers.isConnected() { + if let serverIdentifier = prefs.string(forKey: "carPlay-server"), + let selectedServer = Current.servers.server(forServerIdentifier: serverIdentifier) { + setServer(server: selectedServer) + } else if let server = Current.servers.getServer() { + setServer(server: server) + } + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(connectionInfoDidChange), + name: HAConnectionState.didTransitionToStateNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(connectionInfoDidChange), + name: HomeAssistantAPI.didConnectNotification, + object: nil + ) + } + + func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didDisconnect interfaceController: CPInterfaceController, from window: CPWindow) { + entitiesStateSubscribeCancelable?.cancel() + entitiesStateSubscribeCancelable = nil + NotificationCenter.default.removeObserver(self) + Current.servers.remove(observer: self) + serverObserver?.cancel() + serverObserver = nil + } +} + +@available(iOS 16.0, *) +extension CarPlayDelegate: CPInterfaceControllerDelegate { + + func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) { + if aTemplate == entitiesGridTemplate?.getTemplate() { + entitiesGridTemplate?.subscribe() + } + } + + func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) { + if aTemplate == entitiesGridTemplate?.getTemplate() { + entitiesGridTemplate?.unsubscribe() + } + } +} + +@available(iOS 16.0, *) +extension CarPlayDelegate : ServerObserver { + func serversDidChange(_ serverManager: ServerManager) { + self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + + if Current.servers.getServer(id: serverId) == nil { + serverId = nil + } + if serverId == nil, let server = Current.servers.getServer() { + setServer(server: server) + } + if serverManager.all.isEmpty { + entitiesStateSubscribeCancelable?.cancel() + entitiesStateSubscribeCancelable = nil + showNoServerAlert() + } else if self.interfaceController?.presentedTemplate != nil { + self.interfaceController?.dismissTemplate(animated: true) + } + } +} diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Vehicle/Extensions/HAEntityExtension.swift new file mode 100644 index 000000000..8719bc7a6 --- /dev/null +++ b/Sources/Vehicle/Extensions/HAEntityExtension.swift @@ -0,0 +1,219 @@ +// +// HAEntityExtentions.swift +// App +// +// Created by Luis Lopes on 27/02/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import Foundation +import HAKit +import Shared +import PromiseKit +import SwiftUI + +extension HAEntity { + + public static func getIconForDomain(domain: String, size: CGSize) -> UIImage? { + do { + let tmpEntity = try HAEntity(entityId: "\(domain).ha_ios_placeholder", domain: domain, state: "", lastChanged: Date(), lastUpdated: Date(), attributes: [:], context: HAResponseEvent.Context(id: "", userId: nil, parentId: nil)) + return tmpEntity.getIcon(size: size) + } + catch { + return nil + } + } + + func onPress(for api: HomeAssistantAPI) -> Promise{ + let domain = domain + var service : String + switch (domain) { + case "lock": + service = state == "unlocked" ? "lock" : "unlock" + case "cover": + service = state == "open" ? "close_cover" : "open_cover" + case "button","input_button": + service = "press"; + case "scene": + service = "turn_on"; + default: + service = state == "on" ? "turn_off" : "turn_on"; + } + return api.CallService(domain: domain, service: service, serviceData: ["entity_id" : entityId]) + } + + func getIcon(size: CGSize = CGSize(width: 64, height: 64), darkColor: UIColor = UIColor.white) -> UIImage?{ + var icon = attributes.icon ?? "" + + var image : MaterialDesignIcons = MaterialDesignIcons.bookmarkIcon + + if icon.starts(with: "mdi:") { + let mdiIcon = icon.components(separatedBy: ":")[1] + let iconName = mdiIcon.replacingOccurrences(of: "-", with: "_") + image = MaterialDesignIcons(named: iconName) + } else { + var compareState = state + switch (domain) { + case "button": + guard let deviceClass = attributes.dictionary["device_class"] as? String else { break } + if (deviceClass == "restart") { + image = MaterialDesignIcons.restartIcon + } else if (deviceClass == "update") { + image = MaterialDesignIcons.packageUpIcon + } else { + image = MaterialDesignIcons.gestureTapButtonIcon + } + case "cover": + image = getCoverIcon() + case "input_boolean": + if (!entityId.hasSuffix(".ha_ios_placeholder")) { + if (compareState == "on") { + image = MaterialDesignIcons.checkCircleOutlineIcon + } else { + image = MaterialDesignIcons.closeCircleOutlineIcon + } + } else { + image = MaterialDesignIcons.toggleSwitchOutlineIcon + } + case "input_button": + image = MaterialDesignIcons.gestureTapButtonIcon + case "light": + image = MaterialDesignIcons.lightbulbIcon + case "lock": + switch (compareState) { + case "unlocked": + image = MaterialDesignIcons.lockOpenIcon + case "jammed": + image = MaterialDesignIcons.lockAlertIcon + case "locking", "unlocking": + image = MaterialDesignIcons.lockClockIcon + default: + image = MaterialDesignIcons.lockIcon + } + case "person": + image = MaterialDesignIcons.accountIcon + case "scene": + image = MaterialDesignIcons.paletteOutlineIcon + case "script": + image = MaterialDesignIcons.scriptTextOutlineIcon + case "sensor": + image = MaterialDesignIcons.eyeIcon + case "switch": + if (!entityId.hasSuffix(".ha_ios_placeholder")) { + let deviceClass = attributes.dictionary["device_class"] as? String + switch(deviceClass) { + case "outlet": + image = compareState == "on" ? MaterialDesignIcons.powerPlugIcon : MaterialDesignIcons.powerPlugOffIcon + case "switch": + image = compareState == "on" ? MaterialDesignIcons.toggleSwitchIcon : MaterialDesignIcons.toggleSwitchOffIcon + default: + image = MaterialDesignIcons.flashIcon + } + } else { + image = MaterialDesignIcons.lightSwitchIcon + } + case "zone": + image = MaterialDesignIcons.mapMarkerRadiusIcon + default: + image = MaterialDesignIcons.bookmarkIcon + } + } + var iconImage = image.image(ofSize: size, color: nil) + iconImage.imageAsset?.register(image.image(ofSize: size, color: darkColor), with: .init(userInterfaceStyle: .dark)) + return iconImage + } + + private func getCoverIcon() -> MaterialDesignIcons { + let device_class = attributes.dictionary["device_class"] as? String + let state = state + let open = state != "closed" + + switch (device_class) { + case "garage": + switch (state) { + case "opening": return MaterialDesignIcons.arrowUpBoxIcon + case "closing": return MaterialDesignIcons.arrowDownBoxIcon + case "closed": return MaterialDesignIcons.garageIcon + default: return MaterialDesignIcons.garageOpenIcon + } + case "gate": + switch (state) { + case "opening", "closing": return MaterialDesignIcons.gateArrowRightIcon + case "closed": return MaterialDesignIcons.gateIcon + default: return MaterialDesignIcons.gateOpenIcon + } + case "door": + return open ? MaterialDesignIcons.doorOpenIcon : MaterialDesignIcons.doorClosedIcon + case "damper": + return open ? MaterialDesignIcons.circleIcon : MaterialDesignIcons.circleSlice8Icon + case "shutter": + switch (state) { + case "opening": return MaterialDesignIcons.arrowUpBoxIcon + case "closing": return MaterialDesignIcons.arrowDownBoxIcon + case "closed": return MaterialDesignIcons.windowShutterIcon + default: return MaterialDesignIcons.windowShutterOpenIcon + } + case "curtain": + switch (state) { + case "opening": return MaterialDesignIcons.arrowSplitVerticalIcon + case "closing": return MaterialDesignIcons.arrowCollapseHorizontalIcon + case "closed": return MaterialDesignIcons.curtainsClosedIcon + default: return MaterialDesignIcons.curtainsIcon + } + case "blind", "shade": + switch (state) { + case "opening": return MaterialDesignIcons.arrowUpBoxIcon + case "closing": return MaterialDesignIcons.arrowDownBoxIcon + case "closed": return MaterialDesignIcons.blindsIcon + default: return MaterialDesignIcons.blindsOpenIcon + } + default: + switch (state) { + case "opening": return MaterialDesignIcons.arrowUpBoxIcon + case "closing": return MaterialDesignIcons.arrowDownBoxIcon + case "closed": return MaterialDesignIcons.windowClosedIcon + default: return MaterialDesignIcons.windowOpenIcon + } + } + } + + func getFriendlyState() -> String { + var state = state + var friendlyState : String = state + switch(state) { + case "closed": + friendlyState = L10n.State.closed + case "closing": + friendlyState = L10n.State.closing + case "jammed": + friendlyState = L10n.State.jammed + case "locked": + friendlyState = L10n.State.locked + case "locking": + friendlyState = L10n.State.locking + case "off": + friendlyState = L10n.State.off + case "on": + friendlyState = L10n.State.on + case "open": + friendlyState = L10n.State.open + case "opening": + friendlyState = L10n.State.opening + case "unavailable": + friendlyState = L10n.State.unavailable + case "unlocked": + friendlyState = L10n.State.unlocked + case "unlocking": + friendlyState = L10n.State.unlocking + case "unknown": + friendlyState = L10n.State.unknown + default: + break + } + + if (friendlyState == state) { + friendlyState = "\((Date().timeIntervalSinceReferenceDate - lastChanged.timeIntervalSinceReferenceDate).rounded().description) sec" + } + return friendlyState + } +} diff --git a/Sources/Vehicle/Extensions/ServerManagerExtension.swift b/Sources/Vehicle/Extensions/ServerManagerExtension.swift new file mode 100644 index 000000000..9ddf87daa --- /dev/null +++ b/Sources/Vehicle/Extensions/ServerManagerExtension.swift @@ -0,0 +1,32 @@ +// +// ServerManagerExtension.swift +// App +// +// Created by Luis Lopes on 06/03/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import Foundation +import Shared + +extension ServerManager { + public func isConnected() -> Bool { + return all.contains(where: { isConnected(server: $0) }) + } + + public func isConnected(server : Server) -> Bool{ + switch Current.api(for: server).connection.state { + case .ready(version: _): + return true + default: + return false + } + } + + public func getServer(id : Identifier? = nil) -> Server? { + guard let id = id else { + return all.first(where: {isConnected(server: $0)} ) + } + return server(for: id) + } +} diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift new file mode 100644 index 000000000..70499f55c --- /dev/null +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -0,0 +1,103 @@ +// +// DomainListTemplate.swift +// App +// +// Created by Luis Lopes on 27/02/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import Foundation +import CarPlay +import HAKit +import Shared + +@available(iOS 16.0, *) +class DomainsListTemplate { + private var title : String + private var listTemplate : CPListTemplate? + private var entities : [HAEntity] + private let listItemHandler : (String, [HAEntity]) -> Void + private var serverButtonHandler: CPBarButtonHandler? + private var domainList : [String] = [] + + init(title: String, entities: [HAEntity], ic: CPInterfaceController, + listItemHandler: @escaping (String, [HAEntity]) -> Void, + serverButtonHandler: CPBarButtonHandler? = nil) + { + self.title = title + self.entities = entities + self.listItemHandler = listItemHandler + self.serverButtonHandler = serverButtonHandler + } + + public func getTemplate() -> CPListTemplate { + guard let listTemplate = listTemplate else { + listTemplate = CPListTemplate(title: title, sections: []) + listTemplate?.emptyViewSubtitleVariants = [L10n.Carplay.Labels.emptyDomainList] + return listTemplate! + } + return listTemplate + } + + public func entitiesUpdate(updateEntities : [HAEntity]) { + entities = updateEntities + updateSection() + } + + func setServerListButton(show : Bool) { + if show { + listTemplate?.trailingNavigationBarButtons = [CPBarButton(title: L10n.Carplay.Labels.servers, handler: serverButtonHandler)] + } else { + listTemplate?.trailingNavigationBarButtons.removeAll() + } + } + + func updateSection() { + let allUniqueDomains = entities.unique(by: {$0.domain}) + let domainsSorted = allUniqueDomains.sorted { $0.domain < $1.domain } + let domains = domainsSorted.map { $0.domain } + + guard domainList != domains else { + return + } + + var items : [CPListItem] = [] + + for domain in domains { + + let itemTitle = CarPlayDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain + let listItem = CPListItem(text: itemTitle, detailText: nil, image: HAEntity.getIconForDomain(domain: domain, size: CPListItem.maximumImageSize)) + listItem.accessoryType = CPListItemAccessoryType.disclosureIndicator + listItem.handler = { [weak self] item, completion in + if let entitiesForSelectedDomain = self?.getEntitiesForDomain(domain: domain) { + self?.listItemHandler(domain, entitiesForSelectedDomain) + } + completion() + } + + items.append(listItem) + } + + domainList = domains + listTemplate?.updateSections([CPListSection(items: items)]) + } + + func getEntitiesForDomain(domain: String) -> [HAEntity] { + return entities.filter {$0.domain == domain} + } +} + +extension Array { + func unique(by: ((Element) -> (T))) -> [Element] { + var set = Set() + var arrayOrdered = [Element]() + for value in self { + let v = by(value) + if !set.contains(v) { + set.insert(v) + arrayOrdered.append(value) + } + } + return arrayOrdered + } +} diff --git a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift new file mode 100644 index 000000000..89882c5c8 --- /dev/null +++ b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift @@ -0,0 +1,130 @@ +// +// EntityGridTemplate.swift +// App +// +// Created by Luis Lopes on 27/02/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import Foundation +import CarPlay +import HAKit +import Shared +import PromiseKit + +@available(iOS 16.0, *) +class EntitiesGridTemplate { + + private let entityIconSize : CGSize = CGSize(width: 64, height: 64) + private var stateSubscriptionToken : HACancellable? + private let title : String + private let domain : String + private var server : Server + private var entities : [HAEntity] = [] + private var gridTemplate: CPGridTemplate? + private var gridPage: Int = 0 + + enum GridPage { + case Next + case Previous + } + + init(title: String, domain: String, server: Server, entities: [HAEntity]) { + self.title = title + self.domain = domain + self.server = server + self.entities = entities + } + + public func getTemplate() -> CPGridTemplate { + guard let gridTemplate = gridTemplate else { + gridTemplate = CPGridTemplate(title: title, gridButtons: getGridButtons()) + return gridTemplate! + } + return gridTemplate + } + + func getGridButtons() -> [CPGridButton] { + var items: [CPGridButton] = [] + + let entitiesSorted = entities.sorted(by: { $0.attributes.friendlyName ?? "" < $1.attributes.friendlyName ?? "" }) + + let entitiesPage = entitiesSorted[(gridPage * CPGridTemplateMaximumItems) ..< min((gridPage * CPGridTemplateMaximumItems) + CPGridTemplateMaximumItems, entitiesSorted.count)] + + for entity in entitiesPage { + let item = CPGridButton(titleVariants: ["\(entity.attributes.friendlyName!) - \(entity.getFriendlyState())"], image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), handler: { button in + firstly { () -> Promise in + let api = Current.api(for: self.server) + return entity.onPress(for: api) + }.done { + }.catch { error in + Current.Log.error("Received error from callService during onPress call: \(error)") + } + }) + items.append(item) + } + return items + } + + func getPageButtons() -> [CPBarButton] { + var barButtons : [CPBarButton] = [] + if entities.count > CPGridTemplateMaximumItems { + let maxPages = entities.count / CPGridTemplateMaximumItems + if gridPage < maxPages { + barButtons.append(CPBarButton(image: MaterialDesignIcons.pageNextIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), handler: { CPBarButton in + self.changePage(to: .Next) + })) + } else { + barButtons.append(CPBarButton(image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), handler: nil)) + } + if gridPage > 0 { + barButtons.append(CPBarButton(image: MaterialDesignIcons.pagePreviousIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), handler: { CPBarButton in + self.changePage(to: .Previous) + })) + } else { + barButtons.append(CPBarButton(image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), handler: nil)) + } + } else { + gridPage = 0 + } + return barButtons + } + + func changePage(to: GridPage) { + switch to { + case .Next: + self.gridPage+=1 + case .Previous: + self.gridPage-=1 + } + gridTemplate?.updateGridButtons(getGridButtons()) + gridTemplate?.trailingNavigationBarButtons = getPageButtons() + } +} + +@available(iOS 16.0, *) +extension EntitiesGridTemplate : EntitiesStateSubscription { + public func subscribe() { + stateSubscriptionToken = Current.api(for: server).connection.caches.states.subscribe { [self] cancellable, cachedStates in + entities.removeAll { entity in + !cachedStates.all.contains(where: {$0.entityId == entity.entityId}) + } + + for entity in cachedStates.all where entity.domain == domain { + if let index = entities.firstIndex(where: {$0.entityId == entity.entityId}) { + entities[index] = entity + } else { + entities.append(entity) + } + } + + gridTemplate?.updateGridButtons(getGridButtons()) + gridTemplate?.trailingNavigationBarButtons = getPageButtons() + } + } + + public func unsubscribe() { + stateSubscriptionToken?.cancel() + stateSubscriptionToken = nil + } +} From d4d6a42b5b61b750b82039c75d78c7d5b4ab6bcc Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Mon, 17 Apr 2023 12:14:21 +0100 Subject: [PATCH 02/41] Fixed lint warnings --- Sources/App/AppDelegate.swift | 4 +- Sources/App/Scenes/CarPlaySceneDelegate.swift | 55 ++++++++----------- .../Extensions/HAEntityExtension.swift | 34 +++++------- .../Extensions/ServerManagerExtension.swift | 16 ++---- .../Templates/DomainsListTemplate.swift | 30 ++++------ .../Templates/EntitiesGridTemplate.swift | 28 ++++------ 6 files changed, 67 insertions(+), 100 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index ee6f6f17a..66b3da091 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -184,8 +184,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions ) -> UISceneConfiguration { - if #available(iOS 16.0, *), connectingSceneSession.role == UISceneSession.Role.carTemplateApplication { - let scene = UISceneConfiguration(name: "CarPlay", sessionRole: connectingSceneSession.role) + if #available(iOS 16.0, *), connectingSceneSession.role == UISceneSession.Role.carTemplateApplication { + let scene = UISceneConfiguration(name: "CarPlay", sessionRole: connectingSceneSession.role) scene.delegateClass = CarPlayDelegate.self return scene } else { diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index 97f2b0d1a..3eb9a5c2a 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -1,16 +1,9 @@ -// -// CarPlayDelegate.swift -// App -// -// Created by Luis Lopes on 15/02/2023. -// Copyright © 2023 Home Assistant. All rights reserved. -// - import CarPlay -import Shared +import Communicator import HAKit import PromiseKit -import Communicator +import Shared + public protocol EntitiesStateSubscription { func subscribe() @@ -18,17 +11,17 @@ public protocol EntitiesStateSubscription { } @available(iOS 16.0, *) -class CarPlayDelegate : UIResponder { +class CarPlayDelegate: UIResponder { public static let SUPPORTED_DOMAINS_WITH_STRING = [ - "button" : L10n.Carplay.Labels.buttons, - "cover" : L10n.Carplay.Labels.covers, - "input_boolean" : L10n.Carplay.Labels.inputBooleans, - "input_button" : L10n.Carplay.Labels.inputButtons, - "light" : L10n.Carplay.Labels.lights, - "lock" : L10n.Carplay.Labels.locks, - "scene" : L10n.Carplay.Labels.scenes, - "script" : L10n.Carplay.Labels.scripts, - "switch" : L10n.Carplay.Labels.switches + "button": L10n.Carplay.Labels.buttons, + "cover": L10n.Carplay.Labels.covers, + "input_boolean": L10n.Carplay.Labels.inputBooleans, + "input_button": L10n.Carplay.Labels.inputButtons, + "light": L10n.Carplay.Labels.lights, + "lock": L10n.Carplay.Labels.locks, + "scene": L10n.Carplay.Labels.scenes, + "script": L10n.Carplay.Labels.scripts, + "switch": L10n.Carplay.Labels.switches ] public let SUPPORTED_DOMAINS = SUPPORTED_DOMAINS_WITH_STRING.keys @@ -42,11 +35,11 @@ class CarPlayDelegate : UIResponder { private var interfaceController: CPInterfaceController? private var filteredEntities: [HAEntity] = [] - private var entitiesGridTemplate : EntitiesGridTemplate? - private var domainsListTemplate : DomainsListTemplate? - private var entitiesStateSubscribeCancelable : HACancellable? - private var serverObserver : HACancellable? - private var serverId : Identifier? { + private var entitiesGridTemplate: EntitiesGridTemplate? + private var domainsListTemplate: DomainsListTemplate? + private var entitiesStateSubscribeCancelable: HACancellable? + private var serverObserver: HACancellable? + private var serverId: Identifier? { didSet { loadEntities() } @@ -80,7 +73,7 @@ class CarPlayDelegate : UIResponder { } func getFilteredAndSortEntities(entities: [HAEntity]) -> [HAEntity] { - var tmpEntities : [HAEntity] = [] + var tmpEntities: [HAEntity] = [] for entity in entities where SUPPORTED_DOMAINS.contains(entity.domain) { tmpEntities.append(entity) @@ -134,7 +127,7 @@ class CarPlayDelegate : UIResponder { return } - let loginAlertAction : CPAlertAction = CPAlertAction(title: L10n.Carplay.Labels.alreadyAddedServer, style: .default) { _ in + let loginAlertAction: CPAlertAction = CPAlertAction(title: L10n.Carplay.Labels.alreadyAddedServer, style: .default) { _ in if !Current.servers.all.isEmpty { self.interfaceController?.dismissTemplate(animated: true) } @@ -144,7 +137,7 @@ class CarPlayDelegate : UIResponder { } func setDomainListTemplate() { - domainsListTemplate = DomainsListTemplate(title: L10n.About.Logo.appTitle, entities: filteredEntities, ic : interfaceController!, + domainsListTemplate = DomainsListTemplate(title: L10n.About.Logo.appTitle, entities: filteredEntities, ic: interfaceController!, listItemHandler: {[weak self] domain, entities in guard let self = self, let server = Current.servers.getServer(id: self.serverId) else { @@ -162,7 +155,7 @@ class CarPlayDelegate : UIResponder { } func setServerListTemplate() { - var serverList : [CPListItem] = [] + var serverList: [CPListItem] = [] for server in Current.servers.all { let serverItem = CPListItem(text: server.info.name, detailText: "\(server.info.connection.activeURLType.description) - \(server.info.connection.activeURL().absoluteString)") serverItem.handler = { [weak self] item, completion in @@ -182,7 +175,7 @@ class CarPlayDelegate : UIResponder { } @available(iOS 16.0, *) -extension CarPlayDelegate : CPTemplateApplicationSceneDelegate { +extension CarPlayDelegate: CPTemplateApplicationSceneDelegate { func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) { self.interfaceController = interfaceController self.interfaceController?.delegate = self @@ -247,7 +240,7 @@ extension CarPlayDelegate: CPInterfaceControllerDelegate { } @available(iOS 16.0, *) -extension CarPlayDelegate : ServerObserver { +extension CarPlayDelegate: ServerObserver { func serversDidChange(_ serverManager: ServerManager) { self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Vehicle/Extensions/HAEntityExtension.swift index 8719bc7a6..39fb89ad1 100644 --- a/Sources/Vehicle/Extensions/HAEntityExtension.swift +++ b/Sources/Vehicle/Extensions/HAEntityExtension.swift @@ -1,22 +1,18 @@ -// -// HAEntityExtentions.swift -// App -// -// Created by Luis Lopes on 27/02/2023. -// Copyright © 2023 Home Assistant. All rights reserved. -// - import Foundation import HAKit -import Shared import PromiseKit +import Shared import SwiftUI extension HAEntity { - public static func getIconForDomain(domain: String, size: CGSize) -> UIImage? { do { - let tmpEntity = try HAEntity(entityId: "\(domain).ha_ios_placeholder", domain: domain, state: "", lastChanged: Date(), lastUpdated: Date(), attributes: [:], context: HAResponseEvent.Context(id: "", userId: nil, parentId: nil)) + let tmpEntity = try HAEntity(entityId: "\(domain).ha_ios_placeholder", + domain: domain, state: "", + lastChanged: Date(), + lastUpdated: Date(), + attributes: [:], + context: HAResponseEvent.Context(id: "", userId: nil, parentId: nil)) return tmpEntity.getIcon(size: size) } catch { @@ -26,7 +22,7 @@ extension HAEntity { func onPress(for api: HomeAssistantAPI) -> Promise{ let domain = domain - var service : String + var service: String switch (domain) { case "lock": service = state == "unlocked" ? "lock" : "unlock" @@ -39,20 +35,20 @@ extension HAEntity { default: service = state == "on" ? "turn_off" : "turn_on"; } - return api.CallService(domain: domain, service: service, serviceData: ["entity_id" : entityId]) + return api.CallService(domain: domain, service: service, serviceData: ["entity_id": entityId]) } func getIcon(size: CGSize = CGSize(width: 64, height: 64), darkColor: UIColor = UIColor.white) -> UIImage?{ - var icon = attributes.icon ?? "" + let icon = attributes.icon ?? "" - var image : MaterialDesignIcons = MaterialDesignIcons.bookmarkIcon + var image: MaterialDesignIcons = MaterialDesignIcons.bookmarkIcon if icon.starts(with: "mdi:") { let mdiIcon = icon.components(separatedBy: ":")[1] let iconName = mdiIcon.replacingOccurrences(of: "-", with: "_") image = MaterialDesignIcons(named: iconName) } else { - var compareState = state + let compareState = state switch (domain) { case "button": guard let deviceClass = attributes.dictionary["device_class"] as? String else { break } @@ -118,7 +114,7 @@ extension HAEntity { image = MaterialDesignIcons.bookmarkIcon } } - var iconImage = image.image(ofSize: size, color: nil) + let iconImage = image.image(ofSize: size, color: nil) iconImage.imageAsset?.register(image.image(ofSize: size, color: darkColor), with: .init(userInterfaceStyle: .dark)) return iconImage } @@ -178,8 +174,8 @@ extension HAEntity { } func getFriendlyState() -> String { - var state = state - var friendlyState : String = state + let state = state + var friendlyState: String = state switch(state) { case "closed": friendlyState = L10n.State.closed diff --git a/Sources/Vehicle/Extensions/ServerManagerExtension.swift b/Sources/Vehicle/Extensions/ServerManagerExtension.swift index 9ddf87daa..0238db3c4 100644 --- a/Sources/Vehicle/Extensions/ServerManagerExtension.swift +++ b/Sources/Vehicle/Extensions/ServerManagerExtension.swift @@ -1,20 +1,12 @@ -// -// ServerManagerExtension.swift -// App -// -// Created by Luis Lopes on 06/03/2023. -// Copyright © 2023 Home Assistant. All rights reserved. -// - import Foundation import Shared -extension ServerManager { - public func isConnected() -> Bool { +public extension ServerManager { + func isConnected() -> Bool { return all.contains(where: { isConnected(server: $0) }) } - public func isConnected(server : Server) -> Bool{ + func isConnected(server: Server) -> Bool{ switch Current.api(for: server).connection.state { case .ready(version: _): return true @@ -23,7 +15,7 @@ extension ServerManager { } } - public func getServer(id : Identifier? = nil) -> Server? { + func getServer(id: Identifier? = nil) -> Server? { guard let id = id else { return all.first(where: {isConnected(server: $0)} ) } diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 70499f55c..db32e203d 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -1,24 +1,16 @@ -// -// DomainListTemplate.swift -// App -// -// Created by Luis Lopes on 27/02/2023. -// Copyright © 2023 Home Assistant. All rights reserved. -// - -import Foundation import CarPlay +import Foundation import HAKit import Shared @available(iOS 16.0, *) class DomainsListTemplate { - private var title : String - private var listTemplate : CPListTemplate? - private var entities : [HAEntity] - private let listItemHandler : (String, [HAEntity]) -> Void + private var title: String + private var listTemplate: CPListTemplate? + private var entities: [HAEntity] + private let listItemHandler: (String, [HAEntity]) -> Void private var serverButtonHandler: CPBarButtonHandler? - private var domainList : [String] = [] + private var domainList: [String] = [] init(title: String, entities: [HAEntity], ic: CPInterfaceController, listItemHandler: @escaping (String, [HAEntity]) -> Void, @@ -39,12 +31,12 @@ class DomainsListTemplate { return listTemplate } - public func entitiesUpdate(updateEntities : [HAEntity]) { + public func entitiesUpdate(updateEntities: [HAEntity]) { entities = updateEntities updateSection() } - func setServerListButton(show : Bool) { + func setServerListButton(show: Bool) { if show { listTemplate?.trailingNavigationBarButtons = [CPBarButton(title: L10n.Carplay.Labels.servers, handler: serverButtonHandler)] } else { @@ -61,12 +53,14 @@ class DomainsListTemplate { return } - var items : [CPListItem] = [] + var items: [CPListItem] = [] for domain in domains { let itemTitle = CarPlayDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain - let listItem = CPListItem(text: itemTitle, detailText: nil, image: HAEntity.getIconForDomain(domain: domain, size: CPListItem.maximumImageSize)) + let listItem = CPListItem(text: itemTitle, + detailText: nil, + image: HAEntity.getIconForDomain(domain: domain, size: CPListItem.maximumImageSize)) listItem.accessoryType = CPListItemAccessoryType.disclosureIndicator listItem.handler = { [weak self] item, completion in if let entitiesForSelectedDomain = self?.getEntitiesForDomain(domain: domain) { diff --git a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift index 89882c5c8..dc4752a4b 100644 --- a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift @@ -1,26 +1,18 @@ -// -// EntityGridTemplate.swift -// App -// -// Created by Luis Lopes on 27/02/2023. -// Copyright © 2023 Home Assistant. All rights reserved. -// - -import Foundation import CarPlay +import Foundation import HAKit -import Shared import PromiseKit +import Shared @available(iOS 16.0, *) class EntitiesGridTemplate { - private let entityIconSize : CGSize = CGSize(width: 64, height: 64) - private var stateSubscriptionToken : HACancellable? - private let title : String - private let domain : String - private var server : Server - private var entities : [HAEntity] = [] + private let entityIconSize: CGSize = CGSize(width: 64, height: 64) + private var stateSubscriptionToken: HACancellable? + private let title: String + private let domain: String + private var server: Server + private var entities: [HAEntity] = [] private var gridTemplate: CPGridTemplate? private var gridPage: Int = 0 @@ -67,7 +59,7 @@ class EntitiesGridTemplate { } func getPageButtons() -> [CPBarButton] { - var barButtons : [CPBarButton] = [] + var barButtons: [CPBarButton] = [] if entities.count > CPGridTemplateMaximumItems { let maxPages = entities.count / CPGridTemplateMaximumItems if gridPage < maxPages { @@ -103,7 +95,7 @@ class EntitiesGridTemplate { } @available(iOS 16.0, *) -extension EntitiesGridTemplate : EntitiesStateSubscription { +extension EntitiesGridTemplate: EntitiesStateSubscription { public func subscribe() { stateSubscriptionToken = Current.api(for: server).connection.caches.states.subscribe { [self] cancellable, cachedStates in entities.removeAll { entity in From 4cffca99bf401471372487f8e98211de6155cab9 Mon Sep 17 00:00:00 2001 From: Joshua Peisach Date: Thu, 9 Nov 2023 19:16:54 -0500 Subject: [PATCH 03/41] Rename friendlyState to localizedState --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 2 +- .../Extensions/HAEntityExtension.swift | 36 +++++++++---------- .../Templates/EntitiesGridTemplate.swift | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index 3eb9a5c2a..8029c4382 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -78,7 +78,7 @@ class CarPlayDelegate: UIResponder { for entity in entities where SUPPORTED_DOMAINS.contains(entity.domain) { tmpEntities.append(entity) } - return tmpEntities.sorted(by: {$0.getFriendlyState() < $1.getFriendlyState()}) + return tmpEntities.sorted(by: {$0.getLocalizedState() < $1.getLocalizedState()}) } func setServer(server: Server) { diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Vehicle/Extensions/HAEntityExtension.swift index 39fb89ad1..0082ada32 100644 --- a/Sources/Vehicle/Extensions/HAEntityExtension.swift +++ b/Sources/Vehicle/Extensions/HAEntityExtension.swift @@ -173,43 +173,43 @@ extension HAEntity { } } - func getFriendlyState() -> String { + func getLocalizedState() -> String { let state = state - var friendlyState: String = state + var localizedState: String = state switch(state) { case "closed": - friendlyState = L10n.State.closed + localizedState = L10n.State.closed case "closing": - friendlyState = L10n.State.closing + localizedState = L10n.State.closing case "jammed": - friendlyState = L10n.State.jammed + localizedState = L10n.State.jammed case "locked": - friendlyState = L10n.State.locked + localizedState = L10n.State.locked case "locking": - friendlyState = L10n.State.locking + localizedState = L10n.State.locking case "off": - friendlyState = L10n.State.off + localizedState = L10n.State.off case "on": - friendlyState = L10n.State.on + localizedState = L10n.State.on case "open": - friendlyState = L10n.State.open + localizedState = L10n.State.open case "opening": - friendlyState = L10n.State.opening + localizedState = L10n.State.opening case "unavailable": - friendlyState = L10n.State.unavailable + localizedState = L10n.State.unavailable case "unlocked": - friendlyState = L10n.State.unlocked + localizedState = L10n.State.unlocked case "unlocking": - friendlyState = L10n.State.unlocking + localizedState = L10n.State.unlocking case "unknown": - friendlyState = L10n.State.unknown + localizedState = L10n.State.unknown default: break } - if (friendlyState == state) { - friendlyState = "\((Date().timeIntervalSinceReferenceDate - lastChanged.timeIntervalSinceReferenceDate).rounded().description) sec" + if (localizedState == state) { + localizedState = "\((Date().timeIntervalSinceReferenceDate - lastChanged.timeIntervalSinceReferenceDate).rounded().description) sec" } - return friendlyState + return localizedState } } diff --git a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift index dc4752a4b..9396f7de0 100644 --- a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift @@ -44,7 +44,7 @@ class EntitiesGridTemplate { let entitiesPage = entitiesSorted[(gridPage * CPGridTemplateMaximumItems) ..< min((gridPage * CPGridTemplateMaximumItems) + CPGridTemplateMaximumItems, entitiesSorted.count)] for entity in entitiesPage { - let item = CPGridButton(titleVariants: ["\(entity.attributes.friendlyName!) - \(entity.getFriendlyState())"], image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), handler: { button in + let item = CPGridButton(titleVariants: ["\(entity.attributes.friendlyName!) - \(entity.getLocalizedState())"], image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), handler: { button in firstly { () -> Promise in let api = Current.api(for: self.server) return entity.onPress(for: api) From 7f1628c4779606b18476e4ab37700d35fdc78728 Mon Sep 17 00:00:00 2001 From: LuisFALopes <95639878+LuisFALopes@users.noreply.github.com> Date: Tue, 21 Nov 2023 12:37:59 +0000 Subject: [PATCH 04/41] Update Sources/App/Resources/en.lproj/Localizable.strings Co-authored-by: Bram Kragten --- Sources/App/Resources/en.lproj/Localizable.strings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index e9fff6a55..01000b158 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -796,7 +796,7 @@ Home Assistant is free and open source home automation software with a focus on "carplay.labels.switches" = "Switches"; "carplay.labels.servers" = "Servers"; "carplay.labels.empty_domain_list" = "No domains available"; -"carplay.labels.no_servers_available" = "No servers available. Add a server at home assistant Companion App."; +"carplay.labels.no_servers_available" = "No servers available. Add a server in the app."; "carplay.labels.already_added_server" = "Already added"; "state.auto" = "Auto"; "state.cleaning" = "Cleaning"; From a63ffa7b769a8c2dcae79361de53d47c48b7b170 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Tue, 21 Nov 2023 18:42:54 +0000 Subject: [PATCH 05/41] Changed CarPlay scene configuration. --- Sources/App/AppDelegate.swift | 4 +--- Sources/App/Resources/Info.plist | 11 +++++++++++ Sources/App/Scenes/CarPlaySceneDelegate.swift | 14 +++++++------- Sources/App/Scenes/SceneActivity.swift | 8 +++++++- .../Vehicle/Templates/DomainsListTemplate.swift | 2 +- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 66b3da091..769653013 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -185,9 +185,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { options: UIScene.ConnectionOptions ) -> UISceneConfiguration { if #available(iOS 16.0, *), connectingSceneSession.role == UISceneSession.Role.carTemplateApplication { - let scene = UISceneConfiguration(name: "CarPlay", sessionRole: connectingSceneSession.role) - scene.delegateClass = CarPlayDelegate.self - return scene + return SceneActivity.carPlay.configuration } else { let activity = options.userActivities .compactMap { SceneActivity(activityIdentifier: $0.activityType) } diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index bb444b7b2..30b79d17b 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -553,6 +553,17 @@ UISceneConfigurations + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneClassName + CPTemplateApplicationScene + UISceneConfigurationName + CarPlay + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate + + UIWindowSceneSessionRoleApplication diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index 8029c4382..e4f44adc3 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -11,7 +11,7 @@ public protocol EntitiesStateSubscription { } @available(iOS 16.0, *) -class CarPlayDelegate: UIResponder { +class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { public static let SUPPORTED_DOMAINS_WITH_STRING = [ "button": L10n.Carplay.Labels.buttons, "cover": L10n.Carplay.Labels.covers, @@ -144,7 +144,7 @@ class CarPlayDelegate: UIResponder { return } - let itemTitle = CarPlayDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain + let itemTitle = CarPlaySceneDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain self.entitiesGridTemplate = EntitiesGridTemplate(title: itemTitle, domain: domain, server: server, entities: entities) self.interfaceController?.pushTemplate(self.entitiesGridTemplate!.getTemplate(), animated: true) }, serverButtonHandler: { _ in @@ -172,10 +172,10 @@ class CarPlayDelegate: UIResponder { let serverListTemplate = CPListTemplate(title: L10n.Carplay.Labels.servers, sections: [section]) self.interfaceController?.pushTemplate(serverListTemplate, animated: true) } -} +//} -@available(iOS 16.0, *) -extension CarPlayDelegate: CPTemplateApplicationSceneDelegate { +//@available(iOS 16.0, *) +//extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) { self.interfaceController = interfaceController self.interfaceController?.delegate = self @@ -224,7 +224,7 @@ extension CarPlayDelegate: CPTemplateApplicationSceneDelegate { } @available(iOS 16.0, *) -extension CarPlayDelegate: CPInterfaceControllerDelegate { +extension CarPlaySceneDelegate: CPInterfaceControllerDelegate { func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) { if aTemplate == entitiesGridTemplate?.getTemplate() { @@ -240,7 +240,7 @@ extension CarPlayDelegate: CPInterfaceControllerDelegate { } @available(iOS 16.0, *) -extension CarPlayDelegate: ServerObserver { +extension CarPlaySceneDelegate: ServerObserver { func serversDidChange(_ serverManager: ServerManager) { self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) diff --git a/Sources/App/Scenes/SceneActivity.swift b/Sources/App/Scenes/SceneActivity.swift index 733506e25..754e4dae7 100644 --- a/Sources/App/Scenes/SceneActivity.swift +++ b/Sources/App/Scenes/SceneActivity.swift @@ -4,6 +4,7 @@ enum SceneActivity: CaseIterable { case webView case settings case about + case carPlay init(activityIdentifier: String) { self = Self.allCases.first(where: { $0.activityIdentifier == activityIdentifier }) ?? .webView @@ -22,6 +23,7 @@ enum SceneActivity: CaseIterable { case .settings: return "ha.settings" case .webView: return "ha.webview" case .about: return "ha.about" + case .carPlay: return "ha.carPlay" } } @@ -30,10 +32,14 @@ enum SceneActivity: CaseIterable { case .webView: return "WebView" case .settings: return "Settings" case .about: return "About" + case .carPlay: return "CarPlay" } } var configuration: UISceneConfiguration { - .init(name: configurationName, sessionRole: .windowApplication) + switch self { + case .webView,.settings,.about: return .init(name: configurationName, sessionRole: .windowApplication) + case .carPlay: return .init(name: configurationName, sessionRole: .carTemplateApplication) + } } } diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index db32e203d..52d313e3f 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -57,7 +57,7 @@ class DomainsListTemplate { for domain in domains { - let itemTitle = CarPlayDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain + let itemTitle = CarPlaySceneDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain let listItem = CPListItem(text: itemTitle, detailText: nil, image: HAEntity.getIconForDomain(domain: domain, size: CPListItem.maximumImageSize)) From 2c2bf872fa162b66802573719fa2a0fc49dd0335 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Tue, 21 Nov 2023 20:32:28 +0000 Subject: [PATCH 06/41] Supported domains refactored; Changed localizedState at HAEntityExtension --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 78 +++++++++++++------ .../Extensions/HAEntityExtension.swift | 52 ++++--------- .../Templates/DomainsListTemplate.swift | 2 +- .../Templates/EntitiesGridTemplate.swift | 2 +- 4 files changed, 72 insertions(+), 62 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index e4f44adc3..a160f29e9 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -4,7 +4,6 @@ import HAKit import PromiseKit import Shared - public protocol EntitiesStateSubscription { func subscribe() func unsubscribe() @@ -12,26 +11,6 @@ public protocol EntitiesStateSubscription { @available(iOS 16.0, *) class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { - public static let SUPPORTED_DOMAINS_WITH_STRING = [ - "button": L10n.Carplay.Labels.buttons, - "cover": L10n.Carplay.Labels.covers, - "input_boolean": L10n.Carplay.Labels.inputBooleans, - "input_button": L10n.Carplay.Labels.inputButtons, - "light": L10n.Carplay.Labels.lights, - "lock": L10n.Carplay.Labels.locks, - "scene": L10n.Carplay.Labels.scenes, - "script": L10n.Carplay.Labels.scripts, - "switch": L10n.Carplay.Labels.switches - ] - - public let SUPPORTED_DOMAINS = SUPPORTED_DOMAINS_WITH_STRING.keys - - private var MAP_DOMAINS = [ - "device_tracker", - "person", - "sensor", - "zone" - ] private var interfaceController: CPInterfaceController? private var filteredEntities: [HAEntity] = [] @@ -75,10 +54,10 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { func getFilteredAndSortEntities(entities: [HAEntity]) -> [HAEntity] { var tmpEntities: [HAEntity] = [] - for entity in entities where SUPPORTED_DOMAINS.contains(entity.domain) { + for entity in entities where CarPlayDomain(domain: entity.domain).isSupported { tmpEntities.append(entity) } - return tmpEntities.sorted(by: {$0.getLocalizedState() < $1.getLocalizedState()}) + return tmpEntities.sorted(by: {$0.localizedState < $1.localizedState}) } func setServer(server: Server) { @@ -144,7 +123,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { return } - let itemTitle = CarPlaySceneDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain + let itemTitle = CarPlayDomain(domain: domain).localizedDescription self.entitiesGridTemplate = EntitiesGridTemplate(title: itemTitle, domain: domain, server: server, entities: entities) self.interfaceController?.pushTemplate(self.entitiesGridTemplate!.getTemplate(), animated: true) }, serverButtonHandler: { _ in @@ -259,3 +238,54 @@ extension CarPlaySceneDelegate: ServerObserver { } } } + +enum CarPlayDomain: CaseIterable { + case button + case cover + case input_boolean + case input_button + case light + case lock + case scene + case script + case switch_button + case unsupported + + var domain: String { + switch self { + case .button: return "button" + case .cover: return "cover" + case .input_boolean: return "input_boolean" + case .input_button: return "input_button" + case .light: return "light" + case .lock: return "lock" + case .scene: return "scene" + case .script: return "script" + case .switch_button: return "switch" + case .unsupported: return "unsupported" + } + } + + var localizedDescription: String { + switch self { + case .button: return L10n.Carplay.Labels.buttons + case .cover: return L10n.Carplay.Labels.covers + case .input_boolean: return L10n.Carplay.Labels.inputBooleans + case .input_button: return L10n.Carplay.Labels.inputButtons + case .light: return L10n.Carplay.Labels.lights + case .lock: return L10n.Carplay.Labels.locks + case .scene: return L10n.Carplay.Labels.scenes + case .script: return L10n.Carplay.Labels.scripts + case .switch_button: return L10n.Carplay.Labels.switches + case .unsupported: return "" + } + } + + var isSupported: Bool { + return self != .unsupported + } + + init(domain: String) { + self = Self.allCases.first(where: { $0.domain == domain }) ?? .unsupported + } +} diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Vehicle/Extensions/HAEntityExtension.swift index 0082ada32..0f6250c67 100644 --- a/Sources/Vehicle/Extensions/HAEntityExtension.swift +++ b/Sources/Vehicle/Extensions/HAEntityExtension.swift @@ -173,43 +173,23 @@ extension HAEntity { } } - func getLocalizedState() -> String { - let state = state - var localizedState: String = state - switch(state) { - case "closed": - localizedState = L10n.State.closed - case "closing": - localizedState = L10n.State.closing - case "jammed": - localizedState = L10n.State.jammed - case "locked": - localizedState = L10n.State.locked - case "locking": - localizedState = L10n.State.locking - case "off": - localizedState = L10n.State.off - case "on": - localizedState = L10n.State.on - case "open": - localizedState = L10n.State.open - case "opening": - localizedState = L10n.State.opening - case "unavailable": - localizedState = L10n.State.unavailable - case "unlocked": - localizedState = L10n.State.unlocked - case "unlocking": - localizedState = L10n.State.unlocking - case "unknown": - localizedState = L10n.State.unknown + var localizedState: String { + switch state { + case "closed": return L10n.State.closed + case "closing": return L10n.State.closing + case "jammed": return L10n.State.jammed + case "locked": return L10n.State.locked + case "locking": return L10n.State.locking + case "off": return L10n.State.off + case "on": return L10n.State.on + case "open": return L10n.State.open + case "opening": return L10n.State.opening + case "unavailable": return L10n.State.unavailable + case "unlocked": return L10n.State.unlocked + case "unlocking": return L10n.State.unlocking + case "unknown": return L10n.State.unknown default: - break - } - - if (localizedState == state) { - localizedState = "\((Date().timeIntervalSinceReferenceDate - lastChanged.timeIntervalSinceReferenceDate).rounded().description) sec" + return "\((Date().timeIntervalSinceReferenceDate - lastChanged.timeIntervalSinceReferenceDate).rounded().description) sec" } - return localizedState } } diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 52d313e3f..4c8745bc6 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -57,7 +57,7 @@ class DomainsListTemplate { for domain in domains { - let itemTitle = CarPlaySceneDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain + let itemTitle = CarPlayDomain(domain: domain).localizedDescription let listItem = CPListItem(text: itemTitle, detailText: nil, image: HAEntity.getIconForDomain(domain: domain, size: CPListItem.maximumImageSize)) diff --git a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift index 9396f7de0..8942eaa51 100644 --- a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift @@ -44,7 +44,7 @@ class EntitiesGridTemplate { let entitiesPage = entitiesSorted[(gridPage * CPGridTemplateMaximumItems) ..< min((gridPage * CPGridTemplateMaximumItems) + CPGridTemplateMaximumItems, entitiesSorted.count)] for entity in entitiesPage { - let item = CPGridButton(titleVariants: ["\(entity.attributes.friendlyName!) - \(entity.getLocalizedState())"], image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), handler: { button in + let item = CPGridButton(titleVariants: ["\(entity.attributes.friendlyName!) - \(entity.localizedState)"], image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), handler: { button in firstly { () -> Promise in let api = Current.api(for: self.server) return entity.onPress(for: api) From 06b30d8424f71b751cf145959013d60b29460ce5 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Tue, 21 Nov 2023 20:44:50 +0000 Subject: [PATCH 07/41] Use the available global UserDefaults. --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index a160f29e9..e2c106420 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -24,8 +24,6 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { } } - let prefs = UserDefaults(suiteName: Constants.AppGroupID)! - func loadEntities() { self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) From fc50980e0c1e61447afb8435ee8cb736e77b1ace Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Wed, 27 Dec 2023 16:11:41 +0000 Subject: [PATCH 08/41] entitlement injected via script. --- Configuration/Entitlements/App-ios.entitlements | 2 -- .../Entitlements/activate_special_entitlements.sh | 11 +++++++++++ Configuration/HomeAssistant.xcconfig | 2 ++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Configuration/Entitlements/App-ios.entitlements b/Configuration/Entitlements/App-ios.entitlements index ed55c7a14..153010f74 100644 --- a/Configuration/Entitlements/App-ios.entitlements +++ b/Configuration/Entitlements/App-ios.entitlements @@ -2,8 +2,6 @@ - com.apple.developer.carplay-driving-task - aps-environment development com.apple.developer.associated-domains diff --git a/Configuration/Entitlements/activate_special_entitlements.sh b/Configuration/Entitlements/activate_special_entitlements.sh index 330e5bf31..64d95f023 100755 --- a/Configuration/Entitlements/activate_special_entitlements.sh +++ b/Configuration/Entitlements/activate_special_entitlements.sh @@ -29,6 +29,17 @@ if [[ $TARGET_NAME = "App" ]]; then fi fi +if [[ $TARGET_NAME = "App" ]]; then + if [[ $CI && $CONFIGURATION != "Release" ]]; then + echo "warning: com.apple.developer.carplay-driving-task disabled for CI" + elif [[ ${ENABLE_DEVICE_NAME} -eq 1 ]]; then + /usr/libexec/PlistBuddy -c "add com.apple.developer.carplay-driving-task bool true" "$ENTITLEMENTS_FILE" + else + echo "warning: com.apple.developer.carplay-driving-task entitlement disabled" + fi +fi + + if [[ $TARGET_NAME = "App" ]]; then if [[ $CI && $CONFIGURATION != "Release" ]]; then echo "warning: Device name disabled for CI" diff --git a/Configuration/HomeAssistant.xcconfig b/Configuration/HomeAssistant.xcconfig index 598e33377..c323133ee 100644 --- a/Configuration/HomeAssistant.xcconfig +++ b/Configuration/HomeAssistant.xcconfig @@ -7,6 +7,7 @@ ENABLE_CRITICAL_ALERTS_QMQYCKL255 = 1 ENABLE_PUSH_PROVIDER_QMQYCKL255 = 1 ENABLE_DEVICE_NAME_QMQYCKL255 = 1 ENABLE_THREAD_NETWORK_CREDENTIALS_QMQYCKL255 = 1 +ENABLE_CARPLAY_QMQYCKL255 = 1 // cascades down PRODUCT_BUNDLE_IDENTIFIER = ${BUNDLE_ID_PREFIX}.HomeAssistant${BUNDLE_ID_SUFFIX}${PROVISIONING_SUFFIX} @@ -30,6 +31,7 @@ ENABLE_CRITICAL_ALERTS[sdk=iphoneos*] = $(ENABLE_CRITICAL_ALERTS_$(DEVELOPMENT_T ENABLE_PUSH_PROVIDER[sdk=iphoneos*] = $(ENABLE_PUSH_PROVIDER_$(DEVELOPMENT_TEAM)) ENABLE_DEVICE_NAME[sdk=iphoneos*] = $(ENABLE_DEVICE_NAME_$(DEVELOPMENT_TEAM)) ENABLE_THREAD_NETWORK_CREDENTIALS[sdk=iphoneos*] = $(ENABLE_THREAD_NETWORK_CREDENTIALS_$(DEVELOPMENT_TEAM)) +ENABLE_CARPLAY[sdk=iphoneos*] = $(ENABLE_CARPLAY_$(DEVELOPMENT_TEAM)) // We mutate the entitlements at build time to support other development teams CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES From c86bac6dec2aacf1ec313f8a4c4f98e0cd023ee5 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Wed, 27 Dec 2023 18:07:20 +0000 Subject: [PATCH 09/41] Fix script --- Configuration/Entitlements/activate_special_entitlements.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Configuration/Entitlements/activate_special_entitlements.sh b/Configuration/Entitlements/activate_special_entitlements.sh index 64d95f023..039f62af1 100755 --- a/Configuration/Entitlements/activate_special_entitlements.sh +++ b/Configuration/Entitlements/activate_special_entitlements.sh @@ -32,7 +32,7 @@ fi if [[ $TARGET_NAME = "App" ]]; then if [[ $CI && $CONFIGURATION != "Release" ]]; then echo "warning: com.apple.developer.carplay-driving-task disabled for CI" - elif [[ ${ENABLE_DEVICE_NAME} -eq 1 ]]; then + elif [[ ${ENABLE_CARPLAY} -eq 1 ]]; then /usr/libexec/PlistBuddy -c "add com.apple.developer.carplay-driving-task bool true" "$ENTITLEMENTS_FILE" else echo "warning: com.apple.developer.carplay-driving-task entitlement disabled" From e5d66b44ebd3f4d76473c2c77e6368cce4899375 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 28 Dec 2023 16:43:50 +0000 Subject: [PATCH 10/41] removed deprecated methods --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 18 ++-- .../Shared/Resources/Swiftgen/Strings.swift | 88 +++++++++++++++++++ .../Extensions/HAEntityExtension.swift | 5 +- .../Templates/EntitiesGridTemplate.swift | 2 +- 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index e2c106420..d06fb25df 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -38,14 +38,14 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { Current.Log.info("No entities available from server \(server.info.name)") filteredEntities.removeAll() self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) - interfaceController?.setRootTemplate(self.domainsListTemplate!.getTemplate(), animated: false) + interfaceController?.setRootTemplate(self.domainsListTemplate!.getTemplate(), animated: false, completion: nil) return } filteredEntities = getFilteredAndSortEntities(entities: Array(allServerEntities)) self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) if let template = self.domainsListTemplate?.getTemplate() { - interfaceController?.setRootTemplate(template, animated: false) + interfaceController?.setRootTemplate(template, animated: false, completion: nil) } } @@ -106,11 +106,11 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { let loginAlertAction: CPAlertAction = CPAlertAction(title: L10n.Carplay.Labels.alreadyAddedServer, style: .default) { _ in if !Current.servers.all.isEmpty { - self.interfaceController?.dismissTemplate(animated: true) + self.interfaceController?.dismissTemplate(animated: true, completion: nil) } } let alertTemplate = CPAlertTemplate(titleVariants: [L10n.Carplay.Labels.noServersAvailable], actions: [loginAlertAction]) - self.interfaceController?.presentTemplate(alertTemplate, animated: true) + self.interfaceController?.presentTemplate(alertTemplate, animated: true, completion: nil) } func setDomainListTemplate() { @@ -123,12 +123,12 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { let itemTitle = CarPlayDomain(domain: domain).localizedDescription self.entitiesGridTemplate = EntitiesGridTemplate(title: itemTitle, domain: domain, server: server, entities: entities) - self.interfaceController?.pushTemplate(self.entitiesGridTemplate!.getTemplate(), animated: true) + self.interfaceController?.pushTemplate(self.entitiesGridTemplate!.getTemplate(), animated: true, completion: nil) }, serverButtonHandler: { _ in self.setServerListTemplate() }) - interfaceController?.setRootTemplate(domainsListTemplate!.getTemplate(), animated: true) + interfaceController?.setRootTemplate(domainsListTemplate!.getTemplate(), animated: true, completion: nil) } func setServerListTemplate() { @@ -138,7 +138,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { serverItem.handler = { [weak self] item, completion in self?.setServer(server: server) if let templates = self?.interfaceController?.templates, templates.count > 1 { - self?.interfaceController?.popTemplate(animated: true) + self?.interfaceController?.popTemplate(animated: true, completion: nil) } completion() } @@ -147,7 +147,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { } let section = CPListSection(items: serverList) let serverListTemplate = CPListTemplate(title: L10n.Carplay.Labels.servers, sections: [section]) - self.interfaceController?.pushTemplate(serverListTemplate, animated: true) + self.interfaceController?.pushTemplate(serverListTemplate, animated: true, completion: nil) } //} @@ -232,7 +232,7 @@ extension CarPlaySceneDelegate: ServerObserver { entitiesStateSubscribeCancelable = nil showNoServerAlert() } else if self.interfaceController?.presentedTemplate != nil { - self.interfaceController?.dismissTemplate(animated: true) + self.interfaceController?.dismissTemplate(animated: true, completion: nil) } } } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index cefb56263..4c28659b2 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -229,6 +229,37 @@ public enum L10n { } } + public enum Carplay { + public enum Labels { + /// Already added + public static var alreadyAddedServer: String { return L10n.tr("Localizable", "carplay.labels.already_added_server") } + /// Buttons + public static var buttons: String { return L10n.tr("Localizable", "carplay.labels.buttons") } + /// Covers + public static var covers: String { return L10n.tr("Localizable", "carplay.labels.covers") } + /// No domains available + public static var emptyDomainList: String { return L10n.tr("Localizable", "carplay.labels.empty_domain_list") } + /// Input Booleans + public static var inputBooleans: String { return L10n.tr("Localizable", "carplay.labels.input_booleans") } + /// Input Buttons + public static var inputButtons: String { return L10n.tr("Localizable", "carplay.labels.input_buttons") } + /// Lights + public static var lights: String { return L10n.tr("Localizable", "carplay.labels.lights") } + /// Locks + public static var locks: String { return L10n.tr("Localizable", "carplay.labels.locks") } + /// No servers available. Add a server in the app. + public static var noServersAvailable: String { return L10n.tr("Localizable", "carplay.labels.no_servers_available") } + /// Scenes + public static var scenes: String { return L10n.tr("Localizable", "carplay.labels.scenes") } + /// Scripts + public static var scripts: String { return L10n.tr("Localizable", "carplay.labels.scripts") } + /// Servers + public static var servers: String { return L10n.tr("Localizable", "carplay.labels.servers") } + /// Switches + public static var switches: String { return L10n.tr("Localizable", "carplay.labels.switches") } + } + } + public enum ClError { public enum Description { /// Deferred mode is not supported for the requested accuracy. @@ -1782,6 +1813,63 @@ public enum L10n { } } + public enum State { + /// Auto + public static var auto: String { return L10n.tr("Localizable", "state.auto") } + /// Cleaning + public static var cleaning: String { return L10n.tr("Localizable", "state.cleaning") } + /// Closed + public static var closed: String { return L10n.tr("Localizable", "state.closed") } + /// Closing + public static var closing: String { return L10n.tr("Localizable", "state.closing") } + /// Cool + public static var cool: String { return L10n.tr("Localizable", "state.cool") } + /// Docked + public static var docked: String { return L10n.tr("Localizable", "state.docked") } + /// Dry + public static var dry: String { return L10n.tr("Localizable", "state.dry") } + /// Error + public static var error: String { return L10n.tr("Localizable", "state.error") } + /// Fan Only + public static var fanOnly: String { return L10n.tr("Localizable", "state.fan_only") } + /// Heat + public static var heat: String { return L10n.tr("Localizable", "state.heat") } + /// Heat Cool + public static var heatCool: String { return L10n.tr("Localizable", "state.heat_cool") } + /// Idle + public static var idle: String { return L10n.tr("Localizable", "state.idle") } + /// Jammed + public static var jammed: String { return L10n.tr("Localizable", "state.jammed") } + /// Locked + public static var locked: String { return L10n.tr("Localizable", "state.locked") } + /// Locking + public static var locking: String { return L10n.tr("Localizable", "state.locking") } + /// Off + public static var off: String { return L10n.tr("Localizable", "state.off") } + /// On + public static var on: String { return L10n.tr("Localizable", "state.on") } + /// Open + public static var `open`: String { return L10n.tr("Localizable", "state.open") } + /// Opening + public static var opening: String { return L10n.tr("Localizable", "state.opening") } + /// Paused + public static var paused: String { return L10n.tr("Localizable", "state.paused") } + /// Recording + public static var recording: String { return L10n.tr("Localizable", "state.recording") } + /// Returning + public static var returning: String { return L10n.tr("Localizable", "state.returning") } + /// Streaming + public static var streaming: String { return L10n.tr("Localizable", "state.streaming") } + /// Unavailable + public static var unavailable: String { return L10n.tr("Localizable", "state.unavailable") } + /// Unknown + public static var unknown: String { return L10n.tr("Localizable", "state.unknown") } + /// Unlocked + public static var unlocked: String { return L10n.tr("Localizable", "state.unlocked") } + /// Unlocking + public static var unlocking: String { return L10n.tr("Localizable", "state.unlocking") } + } + public enum Thread { public enum Credentials { /// Border Agent ID diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Vehicle/Extensions/HAEntityExtension.swift index 0f6250c67..4bd6aca5c 100644 --- a/Sources/Vehicle/Extensions/HAEntityExtension.swift +++ b/Sources/Vehicle/Extensions/HAEntityExtension.swift @@ -189,7 +189,10 @@ extension HAEntity { case "unlocking": return L10n.State.unlocking case "unknown": return L10n.State.unknown default: - return "\((Date().timeIntervalSinceReferenceDate - lastChanged.timeIntervalSinceReferenceDate).rounded().description) sec" + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .pad + formatter.allowedUnits = [.hour, .minute, .second] + return formatter.string(from: lastChanged, to: Date()) ?? state } } } diff --git a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift index 8942eaa51..6d0550d45 100644 --- a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift @@ -44,7 +44,7 @@ class EntitiesGridTemplate { let entitiesPage = entitiesSorted[(gridPage * CPGridTemplateMaximumItems) ..< min((gridPage * CPGridTemplateMaximumItems) + CPGridTemplateMaximumItems, entitiesSorted.count)] for entity in entitiesPage { - let item = CPGridButton(titleVariants: ["\(entity.attributes.friendlyName!) - \(entity.localizedState)"], image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), handler: { button in + let item = CPGridButton(titleVariants: ["\(entity.attributes.friendlyName ?? entity.entityId) - \(entity.localizedState)"], image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), handler: { button in firstly { () -> Promise in let api = Current.api(for: self.server) return entity.onPress(for: api) From 795abc5612851e532e0430c7437ba6b77a9a31ba Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 28 Dec 2023 16:53:09 +0000 Subject: [PATCH 11/41] Changed getServer to not depend on connected status. --- Sources/Vehicle/Extensions/ServerManagerExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Vehicle/Extensions/ServerManagerExtension.swift b/Sources/Vehicle/Extensions/ServerManagerExtension.swift index 0238db3c4..ddda23dc2 100644 --- a/Sources/Vehicle/Extensions/ServerManagerExtension.swift +++ b/Sources/Vehicle/Extensions/ServerManagerExtension.swift @@ -17,7 +17,7 @@ public extension ServerManager { func getServer(id: Identifier? = nil) -> Server? { guard let id = id else { - return all.first(where: {isConnected(server: $0)} ) + return all.first } return server(for: id) } From 02954d4f64616893af274c877953be783d0a167b Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 28 Dec 2023 17:24:03 +0000 Subject: [PATCH 12/41] Removed unnecessary sort --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 2 +- Sources/Vehicle/Templates/EntitiesGridTemplate.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index d06fb25df..5f091c72d 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -55,7 +55,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { for entity in entities where CarPlayDomain(domain: entity.domain).isSupported { tmpEntities.append(entity) } - return tmpEntities.sorted(by: {$0.localizedState < $1.localizedState}) + return tmpEntities } func setServer(server: Server) { diff --git a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift index 6d0550d45..e073a9239 100644 --- a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift @@ -39,7 +39,7 @@ class EntitiesGridTemplate { func getGridButtons() -> [CPGridButton] { var items: [CPGridButton] = [] - let entitiesSorted = entities.sorted(by: { $0.attributes.friendlyName ?? "" < $1.attributes.friendlyName ?? "" }) + let entitiesSorted = entities.sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) let entitiesPage = entitiesSorted[(gridPage * CPGridTemplateMaximumItems) ..< min((gridPage * CPGridTemplateMaximumItems) + CPGridTemplateMaximumItems, entitiesSorted.count)] From d225a8b693bdeaaf780cee04c8afb1dac3acde02 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 28 Dec 2023 17:39:39 +0000 Subject: [PATCH 13/41] Fix linting problems. --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 173 +++++++++-------- Sources/App/Scenes/SceneActivity.swift | 2 +- .../Extensions/HAEntityExtension.swift | 177 +++++++++--------- .../Extensions/ServerManagerExtension.swift | 8 +- .../Templates/DomainsListTemplate.swift | 55 +++--- .../Templates/EntitiesGridTemplate.swift | 94 ++++++---- 6 files changed, 286 insertions(+), 223 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index 5f091c72d..d6fc8ff76 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -11,7 +11,6 @@ public protocol EntitiesStateSubscription { @available(iOS 16.0, *) class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { - private var interfaceController: CPInterfaceController? private var filteredEntities: [HAEntity] = [] private var entitiesGridTemplate: EntitiesGridTemplate? @@ -23,41 +22,41 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { loadEntities() } } - + func loadEntities() { - self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) - + domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + guard let serverId = serverId, let server = Current.servers.server(for: serverId) else { Current.Log.info("No server available to get entities") filteredEntities.removeAll() - self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) + domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) return } - + guard let allServerEntities = Current.api(for: server).connection.caches.states.value?.all else { Current.Log.info("No entities available from server \(server.info.name)") filteredEntities.removeAll() - self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) - interfaceController?.setRootTemplate(self.domainsListTemplate!.getTemplate(), animated: false, completion: nil) + domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) + interfaceController?.setRootTemplate(domainsListTemplate!.getTemplate(), animated: false, completion: nil) return } - + filteredEntities = getFilteredAndSortEntities(entities: Array(allServerEntities)) - self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) - if let template = self.domainsListTemplate?.getTemplate() { + domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) + if let template = domainsListTemplate?.getTemplate() { interfaceController?.setRootTemplate(template, animated: false, completion: nil) } } - + func getFilteredAndSortEntities(entities: [HAEntity]) -> [HAEntity] { var tmpEntities: [HAEntity] = [] - + for entity in entities where CarPlayDomain(domain: entity.domain).isSupported { tmpEntities.append(entity) } return tmpEntities } - + func setServer(server: Server) { serverId = server.identifier serverObserver = server.observe { [weak self] _ in @@ -68,12 +67,12 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { prefs.set(server.identifier.rawValue, forKey: "carPlay-server") subscribeEntitiesUpdates(for: server) } - + @objc private func connectionInfoDidChange() { DispatchQueue.main.async { self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) if self.serverId == nil { - ///No server is selected + /// No server is selected guard let server = Current.servers.getServer() else { Current.Log.info("No server connected") return @@ -82,90 +81,115 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { } } } - + func subscribeEntitiesUpdates(for server: Server) { Current.Log.info("Subscribe for entities update at server \(server.info.name)") entitiesStateSubscribeCancelable?.cancel() - entitiesStateSubscribeCancelable = Current.api(for: server).connection.caches.states.subscribe { [weak self] cancellable, cachedStates in - Current.Log.info("Received entities update of server \(server.info.name)") - guard let self = self else { - cancellable.cancel() - return - } + entitiesStateSubscribeCancelable = Current.api(for: server).connection.caches.states + .subscribe { [weak self] cancellable, _ in + Current.Log.info("Received entities update of server \(server.info.name)") + guard let self = self else { + cancellable.cancel() + return + } - self.loadEntities() - } + self.loadEntities() + } } - - //Templates - + + // Templates + func showNoServerAlert() { - guard self.interfaceController?.presentedTemplate == nil else { + guard interfaceController?.presentedTemplate == nil else { return } - - let loginAlertAction: CPAlertAction = CPAlertAction(title: L10n.Carplay.Labels.alreadyAddedServer, style: .default) { _ in + + let loginAlertAction = CPAlertAction(title: L10n.Carplay.Labels.alreadyAddedServer, style: .default) { _ in if !Current.servers.all.isEmpty { self.interfaceController?.dismissTemplate(animated: true, completion: nil) } } - let alertTemplate = CPAlertTemplate(titleVariants: [L10n.Carplay.Labels.noServersAvailable], actions: [loginAlertAction]) - self.interfaceController?.presentTemplate(alertTemplate, animated: true, completion: nil) + let alertTemplate = CPAlertTemplate( + titleVariants: [L10n.Carplay.Labels.noServersAvailable], + actions: [loginAlertAction] + ) + interfaceController?.presentTemplate(alertTemplate, animated: true, completion: nil) } - + func setDomainListTemplate() { - domainsListTemplate = DomainsListTemplate(title: L10n.About.Logo.appTitle, entities: filteredEntities, ic: interfaceController!, - listItemHandler: {[weak self] domain, entities in - - guard let self = self, let server = Current.servers.getServer(id: self.serverId) else { - return + domainsListTemplate = DomainsListTemplate( + title: L10n.About.Logo.appTitle, + entities: filteredEntities, + ic: interfaceController!, + listItemHandler: { [weak self] domain, entities in + + guard let self = self, let server = Current.servers.getServer(id: self.serverId) else { + return + } + + let itemTitle = CarPlayDomain(domain: domain).localizedDescription + self.entitiesGridTemplate = EntitiesGridTemplate( + title: itemTitle, + domain: domain, + server: server, + entities: entities + ) + self.interfaceController?.pushTemplate( + self.entitiesGridTemplate!.getTemplate(), + animated: true, + completion: nil + ) + }, + serverButtonHandler: { _ in + self.setServerListTemplate() } - - let itemTitle = CarPlayDomain(domain: domain).localizedDescription - self.entitiesGridTemplate = EntitiesGridTemplate(title: itemTitle, domain: domain, server: server, entities: entities) - self.interfaceController?.pushTemplate(self.entitiesGridTemplate!.getTemplate(), animated: true, completion: nil) - }, serverButtonHandler: { _ in - self.setServerListTemplate() - }) - + ) + interfaceController?.setRootTemplate(domainsListTemplate!.getTemplate(), animated: true, completion: nil) } - + func setServerListTemplate() { var serverList: [CPListItem] = [] for server in Current.servers.all { - let serverItem = CPListItem(text: server.info.name, detailText: "\(server.info.connection.activeURLType.description) - \(server.info.connection.activeURL().absoluteString)") - serverItem.handler = { [weak self] item, completion in + let serverItem = CPListItem( + text: server.info.name, + detailText: "\(server.info.connection.activeURLType.description) - \(server.info.connection.activeURL().absoluteString)" + ) + serverItem.handler = { [weak self] _, completion in self?.setServer(server: server) if let templates = self?.interfaceController?.templates, templates.count > 1 { self?.interfaceController?.popTemplate(animated: true, completion: nil) } completion() } - serverItem.accessoryType = self.serverId == server.identifier ? .cloud : .none + serverItem.accessoryType = serverId == server.identifier ? .cloud : .none serverList.append(serverItem) } let section = CPListSection(items: serverList) let serverListTemplate = CPListTemplate(title: L10n.Carplay.Labels.servers, sections: [section]) - self.interfaceController?.pushTemplate(serverListTemplate, animated: true, completion: nil) + interfaceController?.pushTemplate(serverListTemplate, animated: true, completion: nil) } -//} -//@available(iOS 16.0, *) -//extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { - func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) { + // } + + // @available(iOS 16.0, *) + // extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController + ) { self.interfaceController = interfaceController self.interfaceController?.delegate = self - + /// Observer for servers list changes Current.servers.add(observer: self) - + setDomainListTemplate() - + if Current.servers.all.isEmpty { showNoServerAlert() } - + if Current.servers.isConnected() { if let serverIdentifier = prefs.string(forKey: "carPlay-server"), let selectedServer = Current.servers.server(forServerIdentifier: serverIdentifier) { @@ -174,14 +198,14 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { setServer(server: server) } } - + NotificationCenter.default.addObserver( self, selector: #selector(connectionInfoDidChange), name: HAConnectionState.didTransitionToStateNotification, object: nil ) - + NotificationCenter.default.addObserver( self, selector: #selector(connectionInfoDidChange), @@ -189,8 +213,12 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { object: nil ) } - - func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didDisconnect interfaceController: CPInterfaceController, from window: CPWindow) { + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didDisconnect interfaceController: CPInterfaceController, + from window: CPWindow + ) { entitiesStateSubscribeCancelable?.cancel() entitiesStateSubscribeCancelable = nil NotificationCenter.default.removeObserver(self) @@ -202,7 +230,6 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { @available(iOS 16.0, *) extension CarPlaySceneDelegate: CPInterfaceControllerDelegate { - func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) { if aTemplate == entitiesGridTemplate?.getTemplate() { entitiesGridTemplate?.subscribe() @@ -219,8 +246,8 @@ extension CarPlaySceneDelegate: CPInterfaceControllerDelegate { @available(iOS 16.0, *) extension CarPlaySceneDelegate: ServerObserver { func serversDidChange(_ serverManager: ServerManager) { - self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) - + domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + if Current.servers.getServer(id: serverId) == nil { serverId = nil } @@ -231,8 +258,8 @@ extension CarPlaySceneDelegate: ServerObserver { entitiesStateSubscribeCancelable?.cancel() entitiesStateSubscribeCancelable = nil showNoServerAlert() - } else if self.interfaceController?.presentedTemplate != nil { - self.interfaceController?.dismissTemplate(animated: true, completion: nil) + } else if interfaceController?.presentedTemplate != nil { + interfaceController?.dismissTemplate(animated: true, completion: nil) } } } @@ -263,7 +290,7 @@ enum CarPlayDomain: CaseIterable { case .unsupported: return "unsupported" } } - + var localizedDescription: String { switch self { case .button: return L10n.Carplay.Labels.buttons @@ -278,11 +305,11 @@ enum CarPlayDomain: CaseIterable { case .unsupported: return "" } } - + var isSupported: Bool { - return self != .unsupported + self != .unsupported } - + init(domain: String) { self = Self.allCases.first(where: { $0.domain == domain }) ?? .unsupported } diff --git a/Sources/App/Scenes/SceneActivity.swift b/Sources/App/Scenes/SceneActivity.swift index 754e4dae7..92d8546af 100644 --- a/Sources/App/Scenes/SceneActivity.swift +++ b/Sources/App/Scenes/SceneActivity.swift @@ -38,7 +38,7 @@ enum SceneActivity: CaseIterable { var configuration: UISceneConfiguration { switch self { - case .webView,.settings,.about: return .init(name: configurationName, sessionRole: .windowApplication) + case .webView, .settings, .about: return .init(name: configurationName, sessionRole: .windowApplication) case .carPlay: return .init(name: configurationName, sessionRole: .carTemplateApplication) } } diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Vehicle/Extensions/HAEntityExtension.swift index 4bd6aca5c..3c5106dde 100644 --- a/Sources/Vehicle/Extensions/HAEntityExtension.swift +++ b/Sources/Vehicle/Extensions/HAEntityExtension.swift @@ -7,101 +7,105 @@ import SwiftUI extension HAEntity { public static func getIconForDomain(domain: String, size: CGSize) -> UIImage? { do { - let tmpEntity = try HAEntity(entityId: "\(domain).ha_ios_placeholder", - domain: domain, state: "", - lastChanged: Date(), - lastUpdated: Date(), - attributes: [:], - context: HAResponseEvent.Context(id: "", userId: nil, parentId: nil)) + let tmpEntity = try HAEntity( + entityId: "\(domain).ha_ios_placeholder", + domain: domain, + state: "", + lastChanged: Date(), + lastUpdated: Date(), + attributes: [:], + context: HAResponseEvent.Context(id: "", userId: nil, parentId: nil) + ) return tmpEntity.getIcon(size: size) - } - catch { + } catch { return nil } } - - func onPress(for api: HomeAssistantAPI) -> Promise{ + + func onPress(for api: HomeAssistantAPI) -> Promise { let domain = domain var service: String - switch (domain) { - case "lock": - service = state == "unlocked" ? "lock" : "unlock" - case "cover": - service = state == "open" ? "close_cover" : "open_cover" - case "button","input_button": - service = "press"; - case "scene": - service = "turn_on"; - default: - service = state == "on" ? "turn_off" : "turn_on"; + switch domain { + case "lock": + service = state == "unlocked" ? "lock" : "unlock" + case "cover": + service = state == "open" ? "close_cover" : "open_cover" + case "button", "input_button": + service = "press" + case "scene": + service = "turn_on" + default: + service = state == "on" ? "turn_off" : "turn_on" } return api.CallService(domain: domain, service: service, serviceData: ["entity_id": entityId]) } - - func getIcon(size: CGSize = CGSize(width: 64, height: 64), darkColor: UIColor = UIColor.white) -> UIImage?{ + + func getIcon(size: CGSize = CGSize(width: 64, height: 64), darkColor: UIColor = UIColor.white) -> UIImage? { let icon = attributes.icon ?? "" - - var image: MaterialDesignIcons = MaterialDesignIcons.bookmarkIcon - + + var image = MaterialDesignIcons.bookmarkIcon + if icon.starts(with: "mdi:") { let mdiIcon = icon.components(separatedBy: ":")[1] let iconName = mdiIcon.replacingOccurrences(of: "-", with: "_") image = MaterialDesignIcons(named: iconName) } else { let compareState = state - switch (domain) { - case "button": - guard let deviceClass = attributes.dictionary["device_class"] as? String else { break } - if (deviceClass == "restart") { - image = MaterialDesignIcons.restartIcon - } else if (deviceClass == "update") { - image = MaterialDesignIcons.packageUpIcon - } else { - image = MaterialDesignIcons.gestureTapButtonIcon - } - case "cover": - image = getCoverIcon() - case "input_boolean": - if (!entityId.hasSuffix(".ha_ios_placeholder")) { - if (compareState == "on") { - image = MaterialDesignIcons.checkCircleOutlineIcon - } else { - image = MaterialDesignIcons.closeCircleOutlineIcon - } - } else { - image = MaterialDesignIcons.toggleSwitchOutlineIcon - } - case "input_button": + switch domain { + case "button": + guard let deviceClass = attributes.dictionary["device_class"] as? String else { break } + if deviceClass == "restart" { + image = MaterialDesignIcons.restartIcon + } else if deviceClass == "update" { + image = MaterialDesignIcons.packageUpIcon + } else { image = MaterialDesignIcons.gestureTapButtonIcon - case "light": - image = MaterialDesignIcons.lightbulbIcon - case "lock": - switch (compareState) { - case "unlocked": - image = MaterialDesignIcons.lockOpenIcon - case "jammed": - image = MaterialDesignIcons.lockAlertIcon - case "locking", "unlocking": - image = MaterialDesignIcons.lockClockIcon - default: - image = MaterialDesignIcons.lockIcon + } + case "cover": + image = getCoverIcon() + case "input_boolean": + if !entityId.hasSuffix(".ha_ios_placeholder") { + if compareState == "on" { + image = MaterialDesignIcons.checkCircleOutlineIcon + } else { + image = MaterialDesignIcons.closeCircleOutlineIcon } - case "person": - image = MaterialDesignIcons.accountIcon - case "scene": - image = MaterialDesignIcons.paletteOutlineIcon - case "script": - image = MaterialDesignIcons.scriptTextOutlineIcon - case "sensor": - image = MaterialDesignIcons.eyeIcon - case "switch": - if (!entityId.hasSuffix(".ha_ios_placeholder")) { + } else { + image = MaterialDesignIcons.toggleSwitchOutlineIcon + } + case "input_button": + image = MaterialDesignIcons.gestureTapButtonIcon + case "light": + image = MaterialDesignIcons.lightbulbIcon + case "lock": + switch compareState { + case "unlocked": + image = MaterialDesignIcons.lockOpenIcon + case "jammed": + image = MaterialDesignIcons.lockAlertIcon + case "locking", "unlocking": + image = MaterialDesignIcons.lockClockIcon + default: + image = MaterialDesignIcons.lockIcon + } + case "person": + image = MaterialDesignIcons.accountIcon + case "scene": + image = MaterialDesignIcons.paletteOutlineIcon + case "script": + image = MaterialDesignIcons.scriptTextOutlineIcon + case "sensor": + image = MaterialDesignIcons.eyeIcon + case "switch": + if !entityId.hasSuffix(".ha_ios_placeholder") { let deviceClass = attributes.dictionary["device_class"] as? String - switch(deviceClass) { + switch deviceClass { case "outlet": - image = compareState == "on" ? MaterialDesignIcons.powerPlugIcon : MaterialDesignIcons.powerPlugOffIcon + image = compareState == "on" ? MaterialDesignIcons.powerPlugIcon : MaterialDesignIcons + .powerPlugOffIcon case "switch": - image = compareState == "on" ? MaterialDesignIcons.toggleSwitchIcon : MaterialDesignIcons.toggleSwitchOffIcon + image = compareState == "on" ? MaterialDesignIcons.toggleSwitchIcon : MaterialDesignIcons + .toggleSwitchOffIcon default: image = MaterialDesignIcons.flashIcon } @@ -115,25 +119,28 @@ extension HAEntity { } } let iconImage = image.image(ofSize: size, color: nil) - iconImage.imageAsset?.register(image.image(ofSize: size, color: darkColor), with: .init(userInterfaceStyle: .dark)) + iconImage.imageAsset?.register( + image.image(ofSize: size, color: darkColor), + with: .init(userInterfaceStyle: .dark) + ) return iconImage } - + private func getCoverIcon() -> MaterialDesignIcons { let device_class = attributes.dictionary["device_class"] as? String let state = state let open = state != "closed" - - switch (device_class) { + + switch device_class { case "garage": - switch (state) { + switch state { case "opening": return MaterialDesignIcons.arrowUpBoxIcon case "closing": return MaterialDesignIcons.arrowDownBoxIcon case "closed": return MaterialDesignIcons.garageIcon default: return MaterialDesignIcons.garageOpenIcon } case "gate": - switch (state) { + switch state { case "opening", "closing": return MaterialDesignIcons.gateArrowRightIcon case "closed": return MaterialDesignIcons.gateIcon default: return MaterialDesignIcons.gateOpenIcon @@ -143,28 +150,28 @@ extension HAEntity { case "damper": return open ? MaterialDesignIcons.circleIcon : MaterialDesignIcons.circleSlice8Icon case "shutter": - switch (state) { + switch state { case "opening": return MaterialDesignIcons.arrowUpBoxIcon case "closing": return MaterialDesignIcons.arrowDownBoxIcon case "closed": return MaterialDesignIcons.windowShutterIcon default: return MaterialDesignIcons.windowShutterOpenIcon } case "curtain": - switch (state) { + switch state { case "opening": return MaterialDesignIcons.arrowSplitVerticalIcon case "closing": return MaterialDesignIcons.arrowCollapseHorizontalIcon case "closed": return MaterialDesignIcons.curtainsClosedIcon default: return MaterialDesignIcons.curtainsIcon } case "blind", "shade": - switch (state) { + switch state { case "opening": return MaterialDesignIcons.arrowUpBoxIcon case "closing": return MaterialDesignIcons.arrowDownBoxIcon case "closed": return MaterialDesignIcons.blindsIcon default: return MaterialDesignIcons.blindsOpenIcon } default: - switch (state) { + switch state { case "opening": return MaterialDesignIcons.arrowUpBoxIcon case "closing": return MaterialDesignIcons.arrowDownBoxIcon case "closed": return MaterialDesignIcons.windowClosedIcon @@ -172,7 +179,7 @@ extension HAEntity { } } } - + var localizedState: String { switch state { case "closed": return L10n.State.closed diff --git a/Sources/Vehicle/Extensions/ServerManagerExtension.swift b/Sources/Vehicle/Extensions/ServerManagerExtension.swift index ddda23dc2..13f6a3111 100644 --- a/Sources/Vehicle/Extensions/ServerManagerExtension.swift +++ b/Sources/Vehicle/Extensions/ServerManagerExtension.swift @@ -3,10 +3,10 @@ import Shared public extension ServerManager { func isConnected() -> Bool { - return all.contains(where: { isConnected(server: $0) }) + all.contains(where: { isConnected(server: $0) }) } - - func isConnected(server: Server) -> Bool{ + + func isConnected(server: Server) -> Bool { switch Current.api(for: server).connection.state { case .ready(version: _): return true @@ -14,7 +14,7 @@ public extension ServerManager { return false } } - + func getServer(id: Identifier? = nil) -> Server? { guard let id = id else { return all.first diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 4c8745bc6..4504b8983 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -11,17 +11,20 @@ class DomainsListTemplate { private let listItemHandler: (String, [HAEntity]) -> Void private var serverButtonHandler: CPBarButtonHandler? private var domainList: [String] = [] - - init(title: String, entities: [HAEntity], ic: CPInterfaceController, + + init( + title: String, + entities: [HAEntity], + ic: CPInterfaceController, listItemHandler: @escaping (String, [HAEntity]) -> Void, - serverButtonHandler: CPBarButtonHandler? = nil) - { + serverButtonHandler: CPBarButtonHandler? = nil + ) { self.title = title self.entities = entities self.listItemHandler = listItemHandler self.serverButtonHandler = serverButtonHandler } - + public func getTemplate() -> CPListTemplate { guard let listTemplate = listTemplate else { listTemplate = CPListTemplate(title: title, sections: []) @@ -30,59 +33,65 @@ class DomainsListTemplate { } return listTemplate } - + public func entitiesUpdate(updateEntities: [HAEntity]) { entities = updateEntities updateSection() } - + func setServerListButton(show: Bool) { if show { - listTemplate?.trailingNavigationBarButtons = [CPBarButton(title: L10n.Carplay.Labels.servers, handler: serverButtonHandler)] + listTemplate? + .trailingNavigationBarButtons = + [CPBarButton(title: L10n.Carplay.Labels.servers, handler: serverButtonHandler)] } else { listTemplate?.trailingNavigationBarButtons.removeAll() } } - + func updateSection() { - let allUniqueDomains = entities.unique(by: {$0.domain}) + let allUniqueDomains = entities.unique(by: { $0.domain }) let domainsSorted = allUniqueDomains.sorted { $0.domain < $1.domain } - let domains = domainsSorted.map { $0.domain } - + let domains = domainsSorted.map(\.domain) + guard domainList != domains else { return } - + var items: [CPListItem] = [] for domain in domains { - let itemTitle = CarPlayDomain(domain: domain).localizedDescription - let listItem = CPListItem(text: itemTitle, - detailText: nil, - image: HAEntity.getIconForDomain(domain: domain, size: CPListItem.maximumImageSize)) + let listItem = CPListItem( + text: itemTitle, + detailText: nil, + image: HAEntity.getIconForDomain( + domain: domain, + size: CPListItem.maximumImageSize + ) + ) listItem.accessoryType = CPListItemAccessoryType.disclosureIndicator - listItem.handler = { [weak self] item, completion in + listItem.handler = { [weak self] _, completion in if let entitiesForSelectedDomain = self?.getEntitiesForDomain(domain: domain) { self?.listItemHandler(domain, entitiesForSelectedDomain) } completion() } - + items.append(listItem) } - + domainList = domains listTemplate?.updateSections([CPListSection(items: items)]) } - + func getEntitiesForDomain(domain: String) -> [HAEntity] { - return entities.filter {$0.domain == domain} + entities.filter { $0.domain == domain } } } extension Array { - func unique(by: ((Element) -> (T))) -> [Element] { + func unique(by: (Element) -> (T)) -> [Element] { var set = Set() var arrayOrdered = [Element]() for value in self { diff --git a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift index e073a9239..815c05882 100644 --- a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift @@ -6,8 +6,7 @@ import Shared @available(iOS 16.0, *) class EntitiesGridTemplate { - - private let entityIconSize: CGSize = CGSize(width: 64, height: 64) + private let entityIconSize: CGSize = .init(width: 64, height: 64) private var stateSubscriptionToken: HACancellable? private let title: String private let domain: String @@ -15,19 +14,19 @@ class EntitiesGridTemplate { private var entities: [HAEntity] = [] private var gridTemplate: CPGridTemplate? private var gridPage: Int = 0 - + enum GridPage { case Next case Previous } - + init(title: String, domain: String, server: Server, entities: [HAEntity]) { self.title = title self.domain = domain self.server = server self.entities = entities } - + public func getTemplate() -> CPGridTemplate { guard let gridTemplate = gridTemplate else { gridTemplate = CPGridTemplate(title: title, gridButtons: getGridButtons()) @@ -35,59 +34,80 @@ class EntitiesGridTemplate { } return gridTemplate } - + func getGridButtons() -> [CPGridButton] { var items: [CPGridButton] = [] - let entitiesSorted = entities.sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) - - let entitiesPage = entitiesSorted[(gridPage * CPGridTemplateMaximumItems) ..< min((gridPage * CPGridTemplateMaximumItems) + CPGridTemplateMaximumItems, entitiesSorted.count)] - + let entitiesSorted = entities + .sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) + + let entitiesPage = entitiesSorted[(gridPage * CPGridTemplateMaximumItems) ..< min( + (gridPage * CPGridTemplateMaximumItems) + CPGridTemplateMaximumItems, + entitiesSorted.count + )] + for entity in entitiesPage { - let item = CPGridButton(titleVariants: ["\(entity.attributes.friendlyName ?? entity.entityId) - \(entity.localizedState)"], image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), handler: { button in - firstly { () -> Promise in - let api = Current.api(for: self.server) - return entity.onPress(for: api) - }.done { - }.catch { error in - Current.Log.error("Received error from callService during onPress call: \(error)") + let item = CPGridButton( + titleVariants: ["\(entity.attributes.friendlyName ?? entity.entityId) - \(entity.localizedState)"], + image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), + handler: { _ in + firstly { () -> Promise in + let api = Current.api(for: self.server) + return entity.onPress(for: api) + }.done {}.catch { error in + Current.Log.error("Received error from callService during onPress call: \(error)") + } } - }) + ) items.append(item) } return items } - + func getPageButtons() -> [CPBarButton] { - var barButtons: [CPBarButton] = [] + var barButtons: [CPBarButton] = [] if entities.count > CPGridTemplateMaximumItems { let maxPages = entities.count / CPGridTemplateMaximumItems if gridPage < maxPages { - barButtons.append(CPBarButton(image: MaterialDesignIcons.pageNextIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), handler: { CPBarButton in - self.changePage(to: .Next) - })) + barButtons.append(CPBarButton( + image: MaterialDesignIcons.pageNextIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), + handler: { _ in + self.changePage(to: .Next) + } + )) } else { - barButtons.append(CPBarButton(image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), handler: nil)) + barButtons + .append(CPBarButton( + image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), + handler: nil + )) } if gridPage > 0 { - barButtons.append(CPBarButton(image: MaterialDesignIcons.pagePreviousIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), handler: { CPBarButton in - self.changePage(to: .Previous) - })) + barButtons.append(CPBarButton( + image: MaterialDesignIcons.pagePreviousIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), + handler: { _ in + self.changePage(to: .Previous) + } + )) } else { - barButtons.append(CPBarButton(image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), handler: nil)) + barButtons + .append(CPBarButton( + image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), + handler: nil + )) } } else { gridPage = 0 } return barButtons } - + func changePage(to: GridPage) { switch to { case .Next: - self.gridPage+=1 + gridPage += 1 case .Previous: - self.gridPage-=1 + gridPage -= 1 } gridTemplate?.updateGridButtons(getGridButtons()) gridTemplate?.trailingNavigationBarButtons = getPageButtons() @@ -97,24 +117,24 @@ class EntitiesGridTemplate { @available(iOS 16.0, *) extension EntitiesGridTemplate: EntitiesStateSubscription { public func subscribe() { - stateSubscriptionToken = Current.api(for: server).connection.caches.states.subscribe { [self] cancellable, cachedStates in + stateSubscriptionToken = Current.api(for: server).connection.caches.states.subscribe { [self] _, cachedStates in entities.removeAll { entity in - !cachedStates.all.contains(where: {$0.entityId == entity.entityId}) + !cachedStates.all.contains(where: { $0.entityId == entity.entityId }) } - + for entity in cachedStates.all where entity.domain == domain { - if let index = entities.firstIndex(where: {$0.entityId == entity.entityId}) { + if let index = entities.firstIndex(where: { $0.entityId == entity.entityId }) { entities[index] = entity } else { entities.append(entity) } } - + gridTemplate?.updateGridButtons(getGridButtons()) gridTemplate?.trailingNavigationBarButtons = getPageButtons() } } - + public func unsubscribe() { stateSubscriptionToken?.cancel() stateSubscriptionToken = nil From de8de769885f5909d9e97f7bb7e470c10ba22af9 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 28 Dec 2023 17:46:54 +0000 Subject: [PATCH 14/41] Changed method name to match method output. --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index d6fc8ff76..32ef2044f 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -41,14 +41,14 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { return } - filteredEntities = getFilteredAndSortEntities(entities: Array(allServerEntities)) + filteredEntities = getCarPlaySupportedEntities(entities: Array(allServerEntities)) domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) if let template = domainsListTemplate?.getTemplate() { interfaceController?.setRootTemplate(template, animated: false, completion: nil) } } - func getFilteredAndSortEntities(entities: [HAEntity]) -> [HAEntity] { + func getCarPlaySupportedEntities(entities: [HAEntity]) -> [HAEntity] { var tmpEntities: [HAEntity] = [] for entity in entities where CarPlayDomain(domain: entity.domain).isSupported { From 5f3ecc79c24a96b80051b6b8b2c80109895fb044 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 28 Dec 2023 18:34:50 +0000 Subject: [PATCH 15/41] Renamed method. --- Sources/Vehicle/Extensions/HAEntityExtension.swift | 6 +++--- Sources/Vehicle/Templates/DomainsListTemplate.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Vehicle/Extensions/HAEntityExtension.swift index 3c5106dde..d208bde2e 100644 --- a/Sources/Vehicle/Extensions/HAEntityExtension.swift +++ b/Sources/Vehicle/Extensions/HAEntityExtension.swift @@ -5,11 +5,11 @@ import Shared import SwiftUI extension HAEntity { - public static func getIconForDomain(domain: String, size: CGSize) -> UIImage? { + public static func icon(forDomain: String, size: CGSize) -> UIImage? { do { let tmpEntity = try HAEntity( - entityId: "\(domain).ha_ios_placeholder", - domain: domain, + entityId: "\(forDomain).ha_ios_placeholder", + domain: forDomain, state: "", lastChanged: Date(), lastUpdated: Date(), diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 4504b8983..fb3807b17 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -65,8 +65,8 @@ class DomainsListTemplate { let listItem = CPListItem( text: itemTitle, detailText: nil, - image: HAEntity.getIconForDomain( - domain: domain, + image: HAEntity.icon( + forDomain: domain, size: CPListItem.maximumImageSize ) ) From bc1f1ee60b91910c193423dfb7cf6eed049b9d01 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Fri, 29 Dec 2023 15:59:07 +0000 Subject: [PATCH 16/41] Removed code that was checking if the server was connected. --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 12 +++++------- .../Vehicle/Extensions/ServerManagerExtension.swift | 13 ------------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index 32ef2044f..23a4ff5b9 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -190,13 +190,11 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { showNoServerAlert() } - if Current.servers.isConnected() { - if let serverIdentifier = prefs.string(forKey: "carPlay-server"), - let selectedServer = Current.servers.server(forServerIdentifier: serverIdentifier) { - setServer(server: selectedServer) - } else if let server = Current.servers.getServer() { - setServer(server: server) - } + if let serverIdentifier = prefs.string(forKey: "carPlay-server"), + let selectedServer = Current.servers.server(forServerIdentifier: serverIdentifier) { + setServer(server: selectedServer) + } else if let server = Current.servers.getServer() { + setServer(server: server) } NotificationCenter.default.addObserver( diff --git a/Sources/Vehicle/Extensions/ServerManagerExtension.swift b/Sources/Vehicle/Extensions/ServerManagerExtension.swift index 13f6a3111..6d20000e3 100644 --- a/Sources/Vehicle/Extensions/ServerManagerExtension.swift +++ b/Sources/Vehicle/Extensions/ServerManagerExtension.swift @@ -2,19 +2,6 @@ import Foundation import Shared public extension ServerManager { - func isConnected() -> Bool { - all.contains(where: { isConnected(server: $0) }) - } - - func isConnected(server: Server) -> Bool { - switch Current.api(for: server).connection.state { - case .ready(version: _): - return true - default: - return false - } - } - func getServer(id: Identifier? = nil) -> Server? { guard let id = id else { return all.first From 771fd526768e9585c72dd98128ba6f2206d4e982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Tue, 2 Jan 2024 14:44:00 +0100 Subject: [PATCH 17/41] CarPlay improvements --- HomeAssistant.xcodeproj/project.pbxproj | 12 +- .../Resources/en.lproj/Localizable.strings | 3 + Sources/App/Scenes/CarPlaySceneDelegate.swift | 276 ++++++------------ .../Shared/Resources/Swiftgen/Strings.swift | 10 + .../Extensions/ServerManagerExtension.swift | 11 - .../Templates/DomainsListTemplate.swift | 102 +++---- .../Templates/EntitiesGridTemplate.swift | 142 --------- .../Templates/EntitiesListTemplate.swift | 109 +++++++ 8 files changed, 261 insertions(+), 404 deletions(-) delete mode 100644 Sources/Vehicle/Extensions/ServerManagerExtension.swift delete mode 100644 Sources/Vehicle/Templates/EntitiesGridTemplate.swift create mode 100644 Sources/Vehicle/Templates/EntitiesListTemplate.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index e80243929..8678c72a4 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -907,8 +907,7 @@ FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; FD3BC66729BA003B00B19FBE /* HAEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */; }; - FD3BC66929BA008900B19FBE /* ServerManagerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66829BA008900B19FBE /* ServerManagerExtension.swift */; }; - FD3BC66C29BA00D600B19FBE /* EntitiesGridTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* EntitiesGridTemplate.swift */; }; + FD3BC66C29BA00D600B19FBE /* EntitiesListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* EntitiesListTemplate.swift */; }; FD3BC66E29BA010A00B19FBE /* DomainsListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */; }; FD5FEB304713F1E6BFE498DC /* Pods_iOS_Extensions_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE950A9D74B3E7FF5665CB38 /* Pods_iOS_Extensions_NotificationService.framework */; }; /* End PBXBuildFile section */ @@ -2156,8 +2155,7 @@ F534C18A6FD4884F258341C9 /* Pods-iOS-Shared-iOS.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.beta.xcconfig"; sourceTree = ""; }; FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAEntityExtension.swift; sourceTree = ""; }; - FD3BC66829BA008900B19FBE /* ServerManagerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManagerExtension.swift; sourceTree = ""; }; - FD3BC66B29BA00D600B19FBE /* EntitiesGridTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitiesGridTemplate.swift; sourceTree = ""; }; + FD3BC66B29BA00D600B19FBE /* EntitiesListTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitiesListTemplate.swift; sourceTree = ""; }; FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsListTemplate.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -4041,7 +4039,6 @@ isa = PBXGroup; children = ( FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */, - FD3BC66829BA008900B19FBE /* ServerManagerExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -4049,7 +4046,7 @@ FD3BC66A29BA00B100B19FBE /* Templates */ = { isa = PBXGroup; children = ( - FD3BC66B29BA00D600B19FBE /* EntitiesGridTemplate.swift */, + FD3BC66B29BA00D600B19FBE /* EntitiesListTemplate.swift */, FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */, ); path = Templates; @@ -5692,7 +5689,7 @@ 1187DE4224D77CCC00F0A6A6 /* NFCTagViewController.swift in Sources */, D0EEF324214DF2B700D1D360 /* Utils.swift in Sources */, 1101D7F92621479200AAE617 /* SettingsButtonRow.swift in Sources */, - FD3BC66C29BA00D600B19FBE /* EntitiesGridTemplate.swift in Sources */, + FD3BC66C29BA00D600B19FBE /* EntitiesListTemplate.swift in Sources */, B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */, B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */, 119D765F2492F8FA00183C5F /* UIApplication+BackgroundTask.swift in Sources */, @@ -5728,7 +5725,6 @@ 11A71C7124A4648000D9565F /* ZoneManagerEquatableRegion.swift in Sources */, 11E99A5027156854003C8A65 /* OnboardingTerminalViewController.swift in Sources */, 1101568424D770B2009424C9 /* NFCWriter.swift in Sources */, - FD3BC66929BA008900B19FBE /* ServerManagerExtension.swift in Sources */, 11E7C4B02702E03000667342 /* WidgetOpenPageIntent+Observation.swift in Sources */, 1187DE4624D7E1BD00F0A6A6 /* SimulatorNFCManager.swift in Sources */, 1185DF96271FBB9800ED7D9A /* OnboardingAuthLogin.swift in Sources */, diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 01000b158..2651374ef 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -11,6 +11,7 @@ "about.home_assistant_on_facebook.title" = "Home Assistant on Facebook"; "about.home_assistant_on_twitter.title" = "Home Assistant on Twitter"; "about.logo.app_title" = "Home Assistant Companion"; +"about.logo.title" = "Home Assistant"; "about.logo.tagline" = "Awaken Your Home"; "about.review.title" = "Leave a review"; "about.title" = "About"; @@ -785,6 +786,8 @@ Home Assistant is free and open source home automation software with a focus on "widgets.open_page.not_configured" = "No Pages Available"; "widgets.open_page.title" = "Open Page"; "yes_label" = "Yes"; +"carplay.navigation.button.next" = "Next"; +"carplay.navigation.button.previous" = "Previous"; "carplay.labels.buttons" = "Buttons"; "carplay.labels.covers" = "Covers"; "carplay.labels.input_booleans" = "Input Booleans"; diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index 23a4ff5b9..ed227026f 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -10,70 +10,34 @@ public protocol EntitiesStateSubscription { } @available(iOS 16.0, *) -class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { +class CarPlaySceneDelegate: UIResponder { private var interfaceController: CPInterfaceController? - private var filteredEntities: [HAEntity] = [] - private var entitiesGridTemplate: EntitiesGridTemplate? + private var entities: HACache>? + private var entitiesGridTemplate: EntitiesListTemplate? private var domainsListTemplate: DomainsListTemplate? - private var entitiesStateSubscribeCancelable: HACancellable? - private var serverObserver: HACancellable? - private var serverId: Identifier? { - didSet { - loadEntities() - } - } - - func loadEntities() { - domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) - - guard let serverId = serverId, let server = Current.servers.server(for: serverId) else { - Current.Log.info("No server available to get entities") - filteredEntities.removeAll() - domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) - return - } - - guard let allServerEntities = Current.api(for: server).connection.caches.states.value?.all else { - Current.Log.info("No entities available from server \(server.info.name)") - filteredEntities.removeAll() - domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) - interfaceController?.setRootTemplate(domainsListTemplate!.getTemplate(), animated: false, completion: nil) - return - } - filteredEntities = getCarPlaySupportedEntities(entities: Array(allServerEntities)) - domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) - if let template = domainsListTemplate?.getTemplate() { - interfaceController?.setRootTemplate(template, animated: false, completion: nil) - } - } - - func getCarPlaySupportedEntities(entities: [HAEntity]) -> [HAEntity] { - var tmpEntities: [HAEntity] = [] + private let carPlayPreferredServerKey = "carPlay-server" - for entity in entities where CarPlayDomain(domain: entity.domain).isSupported { - tmpEntities.append(entity) - } - return tmpEntities - } + private var serverId: Identifier? - func setServer(server: Server) { + private func setServer(server: Server) { serverId = server.identifier - serverObserver = server.observe { [weak self] _ in - self?.connectionInfoDidChange() - } + prefs.set(server.identifier.rawValue, forKey: carPlayPreferredServerKey) + setDomainListTemplate(for: server) + updateServerListButton() + } - entitiesStateSubscribeCancelable?.cancel() - prefs.set(server.identifier.rawValue, forKey: "carPlay-server") - subscribeEntitiesUpdates(for: server) + private func updateServerListButton() { + domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) } - @objc private func connectionInfoDidChange() { - DispatchQueue.main.async { - self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + @objc private func updateServerList() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.updateServerListButton() if self.serverId == nil { /// No server is selected - guard let server = Current.servers.getServer() else { + guard let server = self.getServer() else { Current.Log.info("No server connected") return } @@ -82,24 +46,7 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { } } - func subscribeEntitiesUpdates(for server: Server) { - Current.Log.info("Subscribe for entities update at server \(server.info.name)") - entitiesStateSubscribeCancelable?.cancel() - entitiesStateSubscribeCancelable = Current.api(for: server).connection.caches.states - .subscribe { [weak self] cancellable, _ in - Current.Log.info("Received entities update of server \(server.info.name)") - guard let self = self else { - cancellable.cancel() - return - } - - self.loadEntities() - } - } - - // Templates - - func showNoServerAlert() { + private func showNoServerAlert() { guard interfaceController?.presentedTemplate == nil else { return } @@ -116,39 +63,29 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { interfaceController?.presentTemplate(alertTemplate, animated: true, completion: nil) } - func setDomainListTemplate() { - domainsListTemplate = DomainsListTemplate( - title: L10n.About.Logo.appTitle, - entities: filteredEntities, - ic: interfaceController!, - listItemHandler: { [weak self] domain, entities in + private func setDomainListTemplate(for server: Server) { + guard let interfaceController else { return } - guard let self = self, let server = Current.servers.getServer(id: self.serverId) else { - return - } + let entities = Current.api(for: server).connection.caches.states - let itemTitle = CarPlayDomain(domain: domain).localizedDescription - self.entitiesGridTemplate = EntitiesGridTemplate( - title: itemTitle, - domain: domain, - server: server, - entities: entities - ) - self.interfaceController?.pushTemplate( - self.entitiesGridTemplate!.getTemplate(), - animated: true, - completion: nil - ) + domainsListTemplate = DomainsListTemplate( + title: server.info.name, + entities: entities, + serverButtonHandler: { [weak self] _ in + self?.setServerListTemplate() }, - serverButtonHandler: { _ in - self.setServerListTemplate() - } + server: server ) - interfaceController?.setRootTemplate(domainsListTemplate!.getTemplate(), animated: true, completion: nil) + guard let domainsListTemplate else { return } + + domainsListTemplate.interfaceController = interfaceController + + interfaceController.setRootTemplate(domainsListTemplate.template, animated: true, completion: nil) + domainsListTemplate.updateSections() } - func setServerListTemplate() { + private func setServerListTemplate() { var serverList: [CPListItem] = [] for server in Current.servers.all { let serverItem = CPListItem( @@ -170,46 +107,64 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { interfaceController?.pushTemplate(serverListTemplate, animated: true, completion: nil) } - // } + private func setEmptyTemplate(interfaceController: CPInterfaceController) { + interfaceController.setRootTemplate(CPInformationTemplate( + title: L10n.About.Logo.title, + layout: .leading, + items: [], + actions: [] + ), animated: true, completion: nil) + } + + /// Get server for ID or first server available + private func getServer(id: Identifier? = nil) -> Server? { + guard let id = id else { + return Current.servers.all.first + } + return Current.servers.server(for: id) + } +} - // @available(iOS 16.0, *) - // extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { +// MARK: - CPTemplateApplicationSceneDelegate +@available(iOS 16.0, *) +extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { func templateApplicationScene( _ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController ) { self.interfaceController = interfaceController - self.interfaceController?.delegate = self - /// Observer for servers list changes - Current.servers.add(observer: self) - - setDomainListTemplate() - - if Current.servers.all.isEmpty { - showNoServerAlert() - } - - if let serverIdentifier = prefs.string(forKey: "carPlay-server"), + if let serverIdentifier = prefs.string(forKey: carPlayPreferredServerKey), let selectedServer = Current.servers.server(forServerIdentifier: serverIdentifier) { setServer(server: selectedServer) - } else if let server = Current.servers.getServer() { + } else if let server = getServer() { setServer(server: server) + } else { + setEmptyTemplate(interfaceController: interfaceController) } + updateServerList() + NotificationCenter.default.addObserver( self, - selector: #selector(connectionInfoDidChange), + selector: #selector(updateServerList), name: HAConnectionState.didTransitionToStateNotification, object: nil ) NotificationCenter.default.addObserver( self, - selector: #selector(connectionInfoDidChange), + selector: #selector(updateServerList), name: HomeAssistantAPI.didConnectNotification, object: nil ) + + /// Observer for servers list changes + Current.servers.add(observer: self) + + if Current.servers.all.isEmpty { + showNoServerAlert() + } } func templateApplicationScene( @@ -217,98 +172,33 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { didDisconnect interfaceController: CPInterfaceController, from window: CPWindow ) { - entitiesStateSubscribeCancelable?.cancel() - entitiesStateSubscribeCancelable = nil NotificationCenter.default.removeObserver(self) Current.servers.remove(observer: self) - serverObserver?.cancel() - serverObserver = nil - } -} - -@available(iOS 16.0, *) -extension CarPlaySceneDelegate: CPInterfaceControllerDelegate { - func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) { - if aTemplate == entitiesGridTemplate?.getTemplate() { - entitiesGridTemplate?.subscribe() - } - } - - func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) { - if aTemplate == entitiesGridTemplate?.getTemplate() { - entitiesGridTemplate?.unsubscribe() - } } } +// MARK: - ServerObserver @available(iOS 16.0, *) extension CarPlaySceneDelegate: ServerObserver { func serversDidChange(_ serverManager: ServerManager) { - domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) - if Current.servers.getServer(id: serverId) == nil { - serverId = nil - } - if serverId == nil, let server = Current.servers.getServer() { - setServer(server: server) - } - if serverManager.all.isEmpty { - entitiesStateSubscribeCancelable?.cancel() - entitiesStateSubscribeCancelable = nil - showNoServerAlert() - } else if interfaceController?.presentedTemplate != nil { - interfaceController?.dismissTemplate(animated: true, completion: nil) + defer { + updateServerListButton() } - } -} -enum CarPlayDomain: CaseIterable { - case button - case cover - case input_boolean - case input_button - case light - case lock - case scene - case script - case switch_button - case unsupported + guard let server = getServer(id: serverId) else { + serverId = nil - var domain: String { - switch self { - case .button: return "button" - case .cover: return "cover" - case .input_boolean: return "input_boolean" - case .input_button: return "input_button" - case .light: return "light" - case .lock: return "lock" - case .scene: return "scene" - case .script: return "script" - case .switch_button: return "switch" - case .unsupported: return "unsupported" - } - } + if let server = getServer() { + setServer(server: server) + } else if interfaceController?.presentedTemplate != nil { + interfaceController?.dismissTemplate(animated: true, completion: nil) + } else { + showNoServerAlert() + } - var localizedDescription: String { - switch self { - case .button: return L10n.Carplay.Labels.buttons - case .cover: return L10n.Carplay.Labels.covers - case .input_boolean: return L10n.Carplay.Labels.inputBooleans - case .input_button: return L10n.Carplay.Labels.inputButtons - case .light: return L10n.Carplay.Labels.lights - case .lock: return L10n.Carplay.Labels.locks - case .scene: return L10n.Carplay.Labels.scenes - case .script: return L10n.Carplay.Labels.scripts - case .switch_button: return L10n.Carplay.Labels.switches - case .unsupported: return "" + return } - } - - var isSupported: Bool { - self != .unsupported - } - - init(domain: String) { - self = Self.allCases.first(where: { $0.domain == domain }) ?? .unsupported + setServer(server: server) } } diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 4c28659b2..3cbabff74 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -109,6 +109,8 @@ public enum L10n { public static var appTitle: String { return L10n.tr("Localizable", "about.logo.app_title") } /// Awaken Your Home public static var tagline: String { return L10n.tr("Localizable", "about.logo.tagline") } + /// Home Assistant + public static var title: String { return L10n.tr("Localizable", "about.logo.title") } } public enum Review { /// Leave a review @@ -258,6 +260,14 @@ public enum L10n { /// Switches public static var switches: String { return L10n.tr("Localizable", "carplay.labels.switches") } } + public enum Navigation { + public enum Button { + /// Next + public static var next: String { return L10n.tr("Localizable", "carplay.navigation.button.next") } + /// Previous + public static var previous: String { return L10n.tr("Localizable", "carplay.navigation.button.previous") } + } + } } public enum ClError { diff --git a/Sources/Vehicle/Extensions/ServerManagerExtension.swift b/Sources/Vehicle/Extensions/ServerManagerExtension.swift deleted file mode 100644 index 6d20000e3..000000000 --- a/Sources/Vehicle/Extensions/ServerManagerExtension.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation -import Shared - -public extension ServerManager { - func getServer(id: Identifier? = nil) -> Server? { - guard let id = id else { - return all.first - } - return server(for: id) - } -} diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index fb3807b17..f701547bd 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -5,27 +5,29 @@ import Shared @available(iOS 16.0, *) class DomainsListTemplate { - private var title: String + private let title: String + private let entitiesCachedStates: HACache + private let serverButtonHandler: CPBarButtonHandler? + private let server: Server + + private var domainList: Set = [] private var listTemplate: CPListTemplate? - private var entities: [HAEntity] - private let listItemHandler: (String, [HAEntity]) -> Void - private var serverButtonHandler: CPBarButtonHandler? - private var domainList: [String] = [] - init( - title: String, - entities: [HAEntity], - ic: CPInterfaceController, - listItemHandler: @escaping (String, [HAEntity]) -> Void, - serverButtonHandler: CPBarButtonHandler? = nil - ) { - self.title = title - self.entities = entities - self.listItemHandler = listItemHandler - self.serverButtonHandler = serverButtonHandler - } + private let allowedDomains: [String] = [ + "light", + "switch", + "button", + "cover", + "input_boolean", + "input_button", + "lock", + "scene", + "script" + ] + + weak var interfaceController: CPInterfaceController? - public func getTemplate() -> CPListTemplate { + var template: CPListTemplate { guard let listTemplate = listTemplate else { listTemplate = CPListTemplate(title: title, sections: []) listTemplate?.emptyViewSubtitleVariants = [L10n.Carplay.Labels.emptyDomainList] @@ -34,9 +36,16 @@ class DomainsListTemplate { return listTemplate } - public func entitiesUpdate(updateEntities: [HAEntity]) { - entities = updateEntities - updateSection() + init( + title: String, + entities: HACache, + serverButtonHandler: CPBarButtonHandler? = nil, + server: Server + ) { + self.title = title + self.entitiesCachedStates = entities + self.serverButtonHandler = serverButtonHandler + self.server = server } func setServerListButton(show: Bool) { @@ -49,19 +58,17 @@ class DomainsListTemplate { } } - func updateSection() { - let allUniqueDomains = entities.unique(by: { $0.domain }) - let domainsSorted = allUniqueDomains.sorted { $0.domain < $1.domain } - let domains = domainsSorted.map(\.domain) - - guard domainList != domains else { - return - } + func updateSections() { var items: [CPListItem] = [] + var domains = Set(entitiesCachedStates.value?.all.map { $0.domain } ?? []) + domains = domains.filter { allowedDomains.contains($0) } + domains = Set(domains.sorted(by: { d1, d2 in + d1 < d2 + })) - for domain in domains { - let itemTitle = CarPlayDomain(domain: domain).localizedDescription + domains.forEach { domain in + let itemTitle = domain let listItem = CPListItem( text: itemTitle, detailText: nil, @@ -72,9 +79,7 @@ class DomainsListTemplate { ) listItem.accessoryType = CPListItemAccessoryType.disclosureIndicator listItem.handler = { [weak self] _, completion in - if let entitiesForSelectedDomain = self?.getEntitiesForDomain(domain: domain) { - self?.listItemHandler(domain, entitiesForSelectedDomain) - } + self?.listItemHandler(domain: domain) completion() } @@ -85,22 +90,19 @@ class DomainsListTemplate { listTemplate?.updateSections([CPListSection(items: items)]) } - func getEntitiesForDomain(domain: String) -> [HAEntity] { - entities.filter { $0.domain == domain } - } -} + private func listItemHandler(domain: String) { + let itemTitle = domain + let entitiesGridTemplate = EntitiesListTemplate( + title: itemTitle, + domain: domain, + server: server, + entitiesCachedStates: entitiesCachedStates + ) -extension Array { - func unique(by: (Element) -> (T)) -> [Element] { - var set = Set() - var arrayOrdered = [Element]() - for value in self { - let v = by(value) - if !set.contains(v) { - set.insert(v) - arrayOrdered.append(value) - } - } - return arrayOrdered + interfaceController?.pushTemplate( + entitiesGridTemplate.getTemplate(), + animated: true, + completion: nil + ) } } diff --git a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift deleted file mode 100644 index 815c05882..000000000 --- a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift +++ /dev/null @@ -1,142 +0,0 @@ -import CarPlay -import Foundation -import HAKit -import PromiseKit -import Shared - -@available(iOS 16.0, *) -class EntitiesGridTemplate { - private let entityIconSize: CGSize = .init(width: 64, height: 64) - private var stateSubscriptionToken: HACancellable? - private let title: String - private let domain: String - private var server: Server - private var entities: [HAEntity] = [] - private var gridTemplate: CPGridTemplate? - private var gridPage: Int = 0 - - enum GridPage { - case Next - case Previous - } - - init(title: String, domain: String, server: Server, entities: [HAEntity]) { - self.title = title - self.domain = domain - self.server = server - self.entities = entities - } - - public func getTemplate() -> CPGridTemplate { - guard let gridTemplate = gridTemplate else { - gridTemplate = CPGridTemplate(title: title, gridButtons: getGridButtons()) - return gridTemplate! - } - return gridTemplate - } - - func getGridButtons() -> [CPGridButton] { - var items: [CPGridButton] = [] - - let entitiesSorted = entities - .sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) - - let entitiesPage = entitiesSorted[(gridPage * CPGridTemplateMaximumItems) ..< min( - (gridPage * CPGridTemplateMaximumItems) + CPGridTemplateMaximumItems, - entitiesSorted.count - )] - - for entity in entitiesPage { - let item = CPGridButton( - titleVariants: ["\(entity.attributes.friendlyName ?? entity.entityId) - \(entity.localizedState)"], - image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), - handler: { _ in - firstly { () -> Promise in - let api = Current.api(for: self.server) - return entity.onPress(for: api) - }.done {}.catch { error in - Current.Log.error("Received error from callService during onPress call: \(error)") - } - } - ) - items.append(item) - } - return items - } - - func getPageButtons() -> [CPBarButton] { - var barButtons: [CPBarButton] = [] - if entities.count > CPGridTemplateMaximumItems { - let maxPages = entities.count / CPGridTemplateMaximumItems - if gridPage < maxPages { - barButtons.append(CPBarButton( - image: MaterialDesignIcons.pageNextIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), - handler: { _ in - self.changePage(to: .Next) - } - )) - } else { - barButtons - .append(CPBarButton( - image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), - handler: nil - )) - } - if gridPage > 0 { - barButtons.append(CPBarButton( - image: MaterialDesignIcons.pagePreviousIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), - handler: { _ in - self.changePage(to: .Previous) - } - )) - } else { - barButtons - .append(CPBarButton( - image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), - handler: nil - )) - } - } else { - gridPage = 0 - } - return barButtons - } - - func changePage(to: GridPage) { - switch to { - case .Next: - gridPage += 1 - case .Previous: - gridPage -= 1 - } - gridTemplate?.updateGridButtons(getGridButtons()) - gridTemplate?.trailingNavigationBarButtons = getPageButtons() - } -} - -@available(iOS 16.0, *) -extension EntitiesGridTemplate: EntitiesStateSubscription { - public func subscribe() { - stateSubscriptionToken = Current.api(for: server).connection.caches.states.subscribe { [self] _, cachedStates in - entities.removeAll { entity in - !cachedStates.all.contains(where: { $0.entityId == entity.entityId }) - } - - for entity in cachedStates.all where entity.domain == domain { - if let index = entities.firstIndex(where: { $0.entityId == entity.entityId }) { - entities[index] = entity - } else { - entities.append(entity) - } - } - - gridTemplate?.updateGridButtons(getGridButtons()) - gridTemplate?.trailingNavigationBarButtons = getPageButtons() - } - } - - public func unsubscribe() { - stateSubscriptionToken?.cancel() - stateSubscriptionToken = nil - } -} diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift new file mode 100644 index 000000000..b544ab770 --- /dev/null +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -0,0 +1,109 @@ +import CarPlay +import Foundation +import HAKit +import PromiseKit +import Shared + +@available(iOS 16.0, *) +class EntitiesListTemplate { + private let entityIconSize: CGSize = .init(width: 64, height: 64) + private var stateSubscriptionToken: HACancellable? + private let title: String + private let domain: String + private var server: Server + private let entitiesCachedStates: HACache + private var listTemplate: CPListTemplate? + private var currentPage: Int = 0 + + /// Maximum number of items per page minus pagination buttons + private var itemsPerPage: Int = CPListTemplate.maximumItemCount - 2 + + private var entitiesSubscriptionToken: HACancellable? + + init(title: String, domain: String, server: Server, entitiesCachedStates: HACache) { + self.title = title + self.domain = domain + self.server = server + self.entitiesCachedStates = entitiesCachedStates + } + + public func getTemplate() -> CPListTemplate { + defer { + updateListItems() + entitiesSubscriptionToken = entitiesCachedStates.subscribe { [weak self] _, _ in + self?.updateListItems() + } + } + + if let listTemplate = listTemplate { + return listTemplate + } else { + listTemplate = CPListTemplate(title: title, sections: []) + return listTemplate! + } + } + + private func updateListItems() { + let entities = entitiesCachedStates.value?.all.filter { $0.domain == domain } + let entitiesSorted = entities?.sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) + + guard let entitiesSorted else { return } + + let startIndex = currentPage * itemsPerPage + let endIndex = min(startIndex + itemsPerPage, entitiesSorted.count) + let entitiesToShow = Array(entitiesSorted[startIndex.. Promise in + guard let self = self else { return .init(error: CPEntityError.unknown) } + let api = Current.api(for: self.server) + return entity.onPress(for: api) + }.done { + completion() + }.catch { error in + Current.Log.error("Received error from callService during onPress call: \(error)") + } + } + + items.append(item) + } + + // Add pagination buttons if needed + if entitiesSorted.count > itemsPerPage { + if currentPage > 0 { + let previousButton = CPListItem(text: L10n.Carplay.Navigation.Button.previous, detailText: nil) + previousButton.handler = { [weak self] _, completion in + self?.currentPage -= 1 + self?.updateListItems() + completion() + } + items.insert(previousButton, at: 0) + } + + if endIndex < entitiesSorted.count { + let nextButton = CPListItem(text: L10n.Carplay.Navigation.Button.next, detailText: nil) + nextButton.handler = { [weak self] _, completion in + self?.currentPage += 1 + self?.updateListItems() + completion() + } + items.append(nextButton) + } + } + + listTemplate?.updateSections([CPListSection(items: items)]) + + } +} + +enum CPEntityError: Error { + case unknown +} From 4595e80fdf217feb2f375f65ce18fc8884532811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Wed, 3 Jan 2024 11:15:09 +0100 Subject: [PATCH 18/41] CarPlay Improvements --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 7 +- .../Templates/DomainsListTemplate.swift | 68 +++++++++++++++---- .../Templates/EntitiesListTemplate.swift | 6 +- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index ed227026f..79323ca10 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -121,11 +121,12 @@ class CarPlaySceneDelegate: UIResponder { guard let id = id else { return Current.servers.all.first } - return Current.servers.server(for: id) + return Current.servers.server(for: id) } } // MARK: - CPTemplateApplicationSceneDelegate + @available(iOS 16.0, *) extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { func templateApplicationScene( @@ -140,7 +141,7 @@ extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { } else if let server = getServer() { setServer(server: server) } else { - setEmptyTemplate(interfaceController: interfaceController) + setEmptyTemplate(interfaceController: interfaceController) } updateServerList() @@ -178,10 +179,10 @@ extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { } // MARK: - ServerObserver + @available(iOS 16.0, *) extension CarPlaySceneDelegate: ServerObserver { func serversDidChange(_ serverManager: ServerManager) { - defer { updateServerListButton() } diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index f701547bd..dc24561a8 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -13,18 +13,6 @@ class DomainsListTemplate { private var domainList: Set = [] private var listTemplate: CPListTemplate? - private let allowedDomains: [String] = [ - "light", - "switch", - "button", - "cover", - "input_boolean", - "input_button", - "lock", - "scene", - "script" - ] - weak var interfaceController: CPInterfaceController? var template: CPListTemplate { @@ -59,10 +47,9 @@ class DomainsListTemplate { } func updateSections() { - var items: [CPListItem] = [] - var domains = Set(entitiesCachedStates.value?.all.map { $0.domain } ?? []) - domains = domains.filter { allowedDomains.contains($0) } + var domains = Set(entitiesCachedStates.value?.all.map(\.domain) ?? []) + domains = domains.filter { CarPlayDomain(domain: $0).isSupported } domains = Set(domains.sorted(by: { d1, d2 in d1 < d2 })) @@ -106,3 +93,54 @@ class DomainsListTemplate { ) } } + +enum CarPlayDomain: CaseIterable { + case button + case cover + case input_boolean + case input_button + case light + case lock + case scene + case script + case `switch` + case unsupported + + var domain: String { + switch self { + case .button: return "button" + case .cover: return "cover" + case .input_boolean: return "input_boolean" + case .input_button: return "input_button" + case .light: return "light" + case .lock: return "lock" + case .scene: return "scene" + case .script: return "script" + case .switch: return "switch" + case .unsupported: return "unsupported" + } + } + + var localizedDescription: String { + switch self { + case .button: return L10n.Carplay.Labels.buttons + case .cover: return L10n.Carplay.Labels.covers + case .input_boolean: return L10n.Carplay.Labels.inputBooleans + case .input_button: return L10n.Carplay.Labels.inputButtons + case .light: return L10n.Carplay.Labels.lights + case .lock: return L10n.Carplay.Labels.locks + case .scene: return L10n.Carplay.Labels.scenes + case .script: return L10n.Carplay.Labels.scripts + case .switch: return L10n.Carplay.Labels.switches + case .unsupported: return "" + } + } + + var isSupported: Bool { + self != .unsupported + } + + init(domain: String) { + self = Self.allCases.first(where: { $0.domain == domain }) ?? .unsupported + } +} diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift index b544ab770..db6d2d8d1 100644 --- a/Sources/Vehicle/Templates/EntitiesListTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -45,13 +45,14 @@ class EntitiesListTemplate { private func updateListItems() { let entities = entitiesCachedStates.value?.all.filter { $0.domain == domain } - let entitiesSorted = entities?.sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) + let entitiesSorted = entities? + .sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) guard let entitiesSorted else { return } let startIndex = currentPage * itemsPerPage let endIndex = min(startIndex + itemsPerPage, entitiesSorted.count) - let entitiesToShow = Array(entitiesSorted[startIndex.. Date: Wed, 3 Jan 2024 13:51:28 +0000 Subject: [PATCH 19/41] Moved pagination buttons to trailingNavigationBarButtons. --- .../Templates/EntitiesListTemplate.swift | 76 ++++++++++++++----- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift index db6d2d8d1..dc682d8f4 100644 --- a/Sources/Vehicle/Templates/EntitiesListTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -16,7 +16,7 @@ class EntitiesListTemplate { private var currentPage: Int = 0 /// Maximum number of items per page minus pagination buttons - private var itemsPerPage: Int = CPListTemplate.maximumItemCount - 2 + private var itemsPerPage: Int = CPListTemplate.maximumItemCount private var entitiesSubscriptionToken: HACancellable? @@ -45,6 +45,7 @@ class EntitiesListTemplate { private func updateListItems() { let entities = entitiesCachedStates.value?.all.filter { $0.domain == domain } + let entitiesSorted = entities? .sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) @@ -79,29 +80,68 @@ class EntitiesListTemplate { // Add pagination buttons if needed if entitiesSorted.count > itemsPerPage { - if currentPage > 0 { - let previousButton = CPListItem(text: L10n.Carplay.Navigation.Button.previous, detailText: nil) - previousButton.handler = { [weak self] _, completion in - self?.currentPage -= 1 - self?.updateListItems() - completion() + listTemplate?.trailingNavigationBarButtons = getPageButtons( + endIndex: endIndex, + currentPage: currentPage, + totalCount: entitiesSorted.count + ) + } + + listTemplate?.updateSections([CPListSection(items: items)]) + } + + func getPageButtons(endIndex: Int, currentPage: Int, totalCount: Int) -> [CPBarButton] { + var barButtons: [CPBarButton] = [] + + let forwardImage = UIImage(systemName: "arrow.forward")! + if endIndex < totalCount { + barButtons.append(CPBarButton( + image: forwardImage, + handler: { _ in + self.changePage(to: .Next) } - items.insert(previousButton, at: 0) - } + )) + } else { + barButtons + .append(CPBarButton( + image: UIImage(size: forwardImage.size, color: UIColor.clear), + handler: nil + )) + } - if endIndex < entitiesSorted.count { - let nextButton = CPListItem(text: L10n.Carplay.Navigation.Button.next, detailText: nil) - nextButton.handler = { [weak self] _, completion in - self?.currentPage += 1 - self?.updateListItems() - completion() + let backwardImage = UIImage(systemName: "arrow.backward")! + if currentPage > 0 { + barButtons.append(CPBarButton( + image: backwardImage, + handler: { _ in + self.changePage(to: .Previous) } - items.append(nextButton) - } + )) + } else { + barButtons + .append(CPBarButton( + image: UIImage(size: backwardImage.size, color: UIColor.clear), + handler: nil + )) } - listTemplate?.updateSections([CPListSection(items: items)]) + return barButtons } + + func changePage(to: GridPage) { + switch to { + case .Next: + currentPage += 1 + case .Previous: + currentPage -= 1 + } + updateListItems() + } +} + +enum GridPage { + case Next + case Previous } enum CPEntityError: Error { From df1652817391b2e0a0fe8ffc4a5b1e8d2f87f213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 11:55:48 +0100 Subject: [PATCH 20/41] Localize domain name --- HomeAssistant.xcodeproj/project.pbxproj | 20 +++++++ Sources/Shared/Domain/Domain.swift | 43 +++++++++++++++ .../Swiftgen/CoreStrings+DomainTitle.swift | 16 ++++++ .../Templates/DomainsListTemplate.swift | 55 +------------------ 4 files changed, 81 insertions(+), 53 deletions(-) create mode 100644 Sources/Shared/Domain/Domain.swift create mode 100644 Sources/Shared/Resources/Swiftgen/CoreStrings+DomainTitle.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 8678c72a4..6665291ca 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -527,6 +527,10 @@ 42CE8FA82B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA92B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; 42CE8FAA2B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; + 42CE8FB02B46C3D900C707F9 /* CoreStrings+DomainTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAE2B46C3D600C707F9 /* CoreStrings+DomainTitle.swift */; }; + 42CE8FB12B46C3DA00C707F9 /* CoreStrings+DomainTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAE2B46C3D600C707F9 /* CoreStrings+DomainTitle.swift */; }; + 42CE8FB22B46C46E00C707F9 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAC2B46C12C00C707F9 /* Domain.swift */; }; + 42CE8FB32B46C46F00C707F9 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAC2B46C12C00C707F9 /* Domain.swift */; }; 42DD84132B14ACAB00936F16 /* Color+ColorAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */; }; 42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84142B14D68C00936F16 /* WebViewExternalBusMessage.swift */; }; 42DD84192B14D83B00936F16 /* WebViewExternalBusMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84182B14D83B00936F16 /* WebViewExternalBusMessageTests.swift */; }; @@ -1680,6 +1684,8 @@ 42CA28BA2B1028330093B31A /* SimulatorThreadClientService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorThreadClientService.swift; sourceTree = ""; }; 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreStrings.swift; sourceTree = ""; }; 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrontendStrings.swift; sourceTree = ""; }; + 42CE8FAC2B46C12C00C707F9 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; + 42CE8FAE2B46C3D600C707F9 /* CoreStrings+DomainTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoreStrings+DomainTitle.swift"; sourceTree = ""; }; 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+ColorAsset.swift"; sourceTree = ""; }; 42DD84142B14D68C00936F16 /* WebViewExternalBusMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewExternalBusMessage.swift; sourceTree = ""; }; 42DD84182B14D83B00936F16 /* WebViewExternalBusMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewExternalBusMessageTests.swift; sourceTree = ""; }; @@ -3132,6 +3138,14 @@ path = Mocks; sourceTree = ""; }; + 42CE8FAB2B46C11E00C707F9 /* Domain */ = { + isa = PBXGroup; + children = ( + 42CE8FAC2B46C12C00C707F9 /* Domain.swift */, + ); + path = Domain; + sourceTree = ""; + }; 42DD84172B14D83400936F16 /* Tests */ = { isa = PBXGroup; children = ( @@ -3797,6 +3811,7 @@ isa = PBXGroup; children = ( 426740A42B17348700C1DD73 /* Assets */, + 42CE8FAB2B46C11E00C707F9 /* Domain */, 42CA28AC2B101D320093B31A /* DesignSystem */, 11B38EE0275C545C00205C7B /* Intents */, D014EEAA212928EC008EA6F5 /* API */, @@ -3975,6 +3990,7 @@ 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */, 11EE9B4524C4E01500404AF8 /* SharedPlist.swift */, D0EEF31F214DE3B300D1D360 /* Strings.swift */, + 42CE8FAE2B46C3D600C707F9 /* CoreStrings+DomainTitle.swift */, ); path = Swiftgen; sourceTree = ""; @@ -5989,6 +6005,7 @@ B67CE89522200F220034C1D0 /* LocationHistory.swift in Sources */, 491E990025D543560077BBE3 /* LogbookEntry.swift in Sources */, 11B38EF2275C54A300205C7B /* CallServiceIntentHandler.swift in Sources */, + 42CE8FB12B46C3DA00C707F9 /* CoreStrings+DomainTitle.swift in Sources */, B67CE8A722200F220034C1D0 /* HAAPI+RequestHelpers.swift in Sources */, 11C4628924B109C100031902 /* WebhookResponseLocation.swift in Sources */, 11C4628C24B1230E00031902 /* WebhookResponseServiceCall.swift in Sources */, @@ -6013,6 +6030,7 @@ 1104FC9225322C1800B8BE34 /* Dictionary+Additions.swift in Sources */, 118261F824F8D6B0000795C6 /* SensorProviderDependencies.swift in Sources */, 11C8E8AD24F36535003E7F89 /* DeviceWrapper.swift in Sources */, + 42CE8FB32B46C46F00C707F9 /* Domain.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6052,6 +6070,7 @@ 118BDA8825A6DBBA00731016 /* FrontmostAppSensor.swift in Sources */, 11EE9B4624C4E01500404AF8 /* SharedPlist.swift in Sources */, 1110836824AFEFA60027A67A /* Promise+WebhookJson.swift in Sources */, + 42CE8FB22B46C46E00C707F9 /* Domain.swift in Sources */, 42F5CAE72B10CDC900409816 /* CardView.swift in Sources */, 42DD84132B14ACAB00936F16 /* Color+ColorAsset.swift in Sources */, 115560E827011E3300A8F818 /* HAPanel.swift in Sources */, @@ -6189,6 +6208,7 @@ B6B74CBA2283983800D58A68 /* CLKComplication+Strings.swift in Sources */, 119385A4249E8E360097F497 /* StorageSensor.swift in Sources */, D05A4D32216DD206009FD1EB /* MJPEGStreamer.swift in Sources */, + 42CE8FB02B46C3D900C707F9 /* CoreStrings+DomainTitle.swift in Sources */, 11C4628B24B1230E00031902 /* WebhookResponseServiceCall.swift in Sources */, D0EEF30A214DD64C00D1D360 /* UIImage+Icons.swift in Sources */, D0EEF303214D8F0300D1D360 /* String+HA.swift in Sources */, diff --git a/Sources/Shared/Domain/Domain.swift b/Sources/Shared/Domain/Domain.swift new file mode 100644 index 000000000..9a00d8b11 --- /dev/null +++ b/Sources/Shared/Domain/Domain.swift @@ -0,0 +1,43 @@ +// +// Domains.swift +// App +// +// Created by Bruno Pantaleão on 04/01/2024. +// Copyright © 2024 Home Assistant. All rights reserved. +// + +import Foundation + +public enum Domain: String, CaseIterable { + case button + case cover + case input_boolean + case input_button + case light + case lock + case scene + case script + case `switch` + + public var carPlaySupportedDomains: [Domain] { + [ + .button, + .cover, + .input_boolean, + .input_button, + .light, + .lock, + .scene, + .script, + .switch + ] + } + + public var localizedDescription: String { + CoreStrings.getDomainLocalizedTitle(domain: self) + } + + public var isCarPlaySupported: Bool { + carPlaySupportedDomains.contains(self) + } +} diff --git a/Sources/Shared/Resources/Swiftgen/CoreStrings+DomainTitle.swift b/Sources/Shared/Resources/Swiftgen/CoreStrings+DomainTitle.swift new file mode 100644 index 000000000..b38873706 --- /dev/null +++ b/Sources/Shared/Resources/Swiftgen/CoreStrings+DomainTitle.swift @@ -0,0 +1,16 @@ +// +// CoreStrings+Assemble.swift +// App +// +// Created by Bruno Pantaleão on 04/01/2024. +// Copyright © 2024 Home Assistant. All rights reserved. +// + +import Foundation + +public extension CoreStrings { + static func getDomainLocalizedTitle(domain: Domain) -> String { + let format = Current.localized.string("component::\(domain.rawValue)::title", "Core") + return String(format: format, locale: Locale.current, arguments: []) + } +} diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index dc24561a8..2299f28a1 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -49,13 +49,13 @@ class DomainsListTemplate { func updateSections() { var items: [CPListItem] = [] var domains = Set(entitiesCachedStates.value?.all.map(\.domain) ?? []) - domains = domains.filter { CarPlayDomain(domain: $0).isSupported } + domains = domains.filter { Domain(rawValue: $0)?.isCarPlaySupported ?? false } domains = Set(domains.sorted(by: { d1, d2 in d1 < d2 })) domains.forEach { domain in - let itemTitle = domain + let itemTitle = Domain(rawValue: domain)?.localizedDescription ?? domain let listItem = CPListItem( text: itemTitle, detailText: nil, @@ -93,54 +93,3 @@ class DomainsListTemplate { ) } } - -enum CarPlayDomain: CaseIterable { - case button - case cover - case input_boolean - case input_button - case light - case lock - case scene - case script - case `switch` - case unsupported - - var domain: String { - switch self { - case .button: return "button" - case .cover: return "cover" - case .input_boolean: return "input_boolean" - case .input_button: return "input_button" - case .light: return "light" - case .lock: return "lock" - case .scene: return "scene" - case .script: return "script" - case .switch: return "switch" - case .unsupported: return "unsupported" - } - } - - var localizedDescription: String { - switch self { - case .button: return L10n.Carplay.Labels.buttons - case .cover: return L10n.Carplay.Labels.covers - case .input_boolean: return L10n.Carplay.Labels.inputBooleans - case .input_button: return L10n.Carplay.Labels.inputButtons - case .light: return L10n.Carplay.Labels.lights - case .lock: return L10n.Carplay.Labels.locks - case .scene: return L10n.Carplay.Labels.scenes - case .script: return L10n.Carplay.Labels.scripts - case .switch: return L10n.Carplay.Labels.switches - case .unsupported: return "" - } - } - - var isSupported: Bool { - self != .unsupported - } - - init(domain: String) { - self = Self.allCases.first(where: { $0.domain == domain }) ?? .unsupported - } -} From d1479fac76c78409ba4b9f570097efa72d649a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 12:45:40 +0100 Subject: [PATCH 21/41] Improve localization --- HomeAssistant.xcodeproj/project.pbxproj | 18 ++++++++++------ .../Shared/Environment/LocalizedManager.swift | 8 +++++++ .../Swiftgen/CoreStrings+DomainTitle.swift | 16 -------------- .../Swiftgen/CoreStrings+Values.swift | 21 +++++++++++++++++++ .../Swiftgen/FrontendStrings+Values.swift | 16 ++++++++++++++ .../Extensions/HAEntityExtension.swift | 21 +------------------ 6 files changed, 58 insertions(+), 42 deletions(-) delete mode 100644 Sources/Shared/Resources/Swiftgen/CoreStrings+DomainTitle.swift create mode 100644 Sources/Shared/Resources/Swiftgen/CoreStrings+Values.swift create mode 100644 Sources/Shared/Resources/Swiftgen/FrontendStrings+Values.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 6665291ca..96ae61f59 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -527,10 +527,12 @@ 42CE8FA82B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA92B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; 42CE8FAA2B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; - 42CE8FB02B46C3D900C707F9 /* CoreStrings+DomainTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAE2B46C3D600C707F9 /* CoreStrings+DomainTitle.swift */; }; - 42CE8FB12B46C3DA00C707F9 /* CoreStrings+DomainTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAE2B46C3D600C707F9 /* CoreStrings+DomainTitle.swift */; }; + 42CE8FB02B46C3D900C707F9 /* CoreStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAE2B46C3D600C707F9 /* CoreStrings+Values.swift */; }; + 42CE8FB12B46C3DA00C707F9 /* CoreStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAE2B46C3D600C707F9 /* CoreStrings+Values.swift */; }; 42CE8FB22B46C46E00C707F9 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAC2B46C12C00C707F9 /* Domain.swift */; }; 42CE8FB32B46C46F00C707F9 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAC2B46C12C00C707F9 /* Domain.swift */; }; + 42CE8FB62B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */; }; + 42CE8FB72B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */; }; 42DD84132B14ACAB00936F16 /* Color+ColorAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */; }; 42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84142B14D68C00936F16 /* WebViewExternalBusMessage.swift */; }; 42DD84192B14D83B00936F16 /* WebViewExternalBusMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84182B14D83B00936F16 /* WebViewExternalBusMessageTests.swift */; }; @@ -1685,7 +1687,8 @@ 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreStrings.swift; sourceTree = ""; }; 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrontendStrings.swift; sourceTree = ""; }; 42CE8FAC2B46C12C00C707F9 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; - 42CE8FAE2B46C3D600C707F9 /* CoreStrings+DomainTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoreStrings+DomainTitle.swift"; sourceTree = ""; }; + 42CE8FAE2B46C3D600C707F9 /* CoreStrings+Values.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoreStrings+Values.swift"; sourceTree = ""; }; + 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FrontendStrings+Values.swift"; sourceTree = ""; }; 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+ColorAsset.swift"; sourceTree = ""; }; 42DD84142B14D68C00936F16 /* WebViewExternalBusMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewExternalBusMessage.swift; sourceTree = ""; }; 42DD84182B14D83B00936F16 /* WebViewExternalBusMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewExternalBusMessageTests.swift; sourceTree = ""; }; @@ -3990,7 +3993,8 @@ 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */, 11EE9B4524C4E01500404AF8 /* SharedPlist.swift */, D0EEF31F214DE3B300D1D360 /* Strings.swift */, - 42CE8FAE2B46C3D600C707F9 /* CoreStrings+DomainTitle.swift */, + 42CE8FAE2B46C3D600C707F9 /* CoreStrings+Values.swift */, + 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */, ); path = Swiftgen; sourceTree = ""; @@ -5918,6 +5922,7 @@ 11169BC6262BE45F005EF90A /* UNNotificationContent+Additions.swift in Sources */, B672334B225DDF410031D629 /* Event.swift in Sources */, 11B38EF5275C54A300205C7B /* GetCameraImageIntentHandler.swift in Sources */, + 42CE8FB72B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */, 11521BBD25400284009C5C72 /* CrashReporter.swift in Sources */, 113A8D4A283C7B1700B9DA32 /* PeriodicUpdateManager.swift in Sources */, B6872E642226841400C475D1 /* MobileAppRegistrationRequest.swift in Sources */, @@ -6005,7 +6010,7 @@ B67CE89522200F220034C1D0 /* LocationHistory.swift in Sources */, 491E990025D543560077BBE3 /* LogbookEntry.swift in Sources */, 11B38EF2275C54A300205C7B /* CallServiceIntentHandler.swift in Sources */, - 42CE8FB12B46C3DA00C707F9 /* CoreStrings+DomainTitle.swift in Sources */, + 42CE8FB12B46C3DA00C707F9 /* CoreStrings+Values.swift in Sources */, B67CE8A722200F220034C1D0 /* HAAPI+RequestHelpers.swift in Sources */, 11C4628924B109C100031902 /* WebhookResponseLocation.swift in Sources */, 11C4628C24B1230E00031902 /* WebhookResponseServiceCall.swift in Sources */, @@ -6108,6 +6113,7 @@ B6B74CBD228399AB00D58A68 /* Action.swift in Sources */, 11CB98CA249E62E700B05222 /* Version+HA.swift in Sources */, 11EE9B4924C5116F00404AF8 /* ModelManager.swift in Sources */, + 42CE8FB62B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */, D0C3DC142134CD4E000C9EE1 /* CMMotion+StringExtensions.swift in Sources */, B6872E662226842100C475D1 /* MobileAppRegistrationResponse.swift in Sources */, D0EEF305214DD0D400D1D360 /* UIColor+HA.swift in Sources */, @@ -6208,7 +6214,7 @@ B6B74CBA2283983800D58A68 /* CLKComplication+Strings.swift in Sources */, 119385A4249E8E360097F497 /* StorageSensor.swift in Sources */, D05A4D32216DD206009FD1EB /* MJPEGStreamer.swift in Sources */, - 42CE8FB02B46C3D900C707F9 /* CoreStrings+DomainTitle.swift in Sources */, + 42CE8FB02B46C3D900C707F9 /* CoreStrings+Values.swift in Sources */, 11C4628B24B1230E00031902 /* WebhookResponseServiceCall.swift in Sources */, D0EEF30A214DD64C00D1D360 /* UIImage+Icons.swift in Sources */, D0EEF303214D8F0300D1D360 /* String+HA.swift in Sources */, diff --git a/Sources/Shared/Environment/LocalizedManager.swift b/Sources/Shared/Environment/LocalizedManager.swift index 75eac4747..d69c6afc8 100644 --- a/Sources/Shared/Environment/LocalizedManager.swift +++ b/Sources/Shared/Environment/LocalizedManager.swift @@ -38,6 +38,14 @@ public class LocalizedManager { return result } + public func core(_ key: String) -> String? { + let result = string(key, "Core") + guard result != key else { + return nil + } + return result + } + public func string(_ key: String, _ table: String) -> String { let defaultValue = bundle.localizedString(forKey: key, value: nil, table: table) let request = StringProviderRequest(key: key, table: table, defaultValue: defaultValue) diff --git a/Sources/Shared/Resources/Swiftgen/CoreStrings+DomainTitle.swift b/Sources/Shared/Resources/Swiftgen/CoreStrings+DomainTitle.swift deleted file mode 100644 index b38873706..000000000 --- a/Sources/Shared/Resources/Swiftgen/CoreStrings+DomainTitle.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// CoreStrings+Assemble.swift -// App -// -// Created by Bruno Pantaleão on 04/01/2024. -// Copyright © 2024 Home Assistant. All rights reserved. -// - -import Foundation - -public extension CoreStrings { - static func getDomainLocalizedTitle(domain: Domain) -> String { - let format = Current.localized.string("component::\(domain.rawValue)::title", "Core") - return String(format: format, locale: Locale.current, arguments: []) - } -} diff --git a/Sources/Shared/Resources/Swiftgen/CoreStrings+Values.swift b/Sources/Shared/Resources/Swiftgen/CoreStrings+Values.swift new file mode 100644 index 000000000..258165dd4 --- /dev/null +++ b/Sources/Shared/Resources/Swiftgen/CoreStrings+Values.swift @@ -0,0 +1,21 @@ +// +// CoreStrings+Assemble.swift +// App +// +// Created by Bruno Pantaleão on 04/01/2024. +// Copyright © 2024 Home Assistant. All rights reserved. +// + +import Foundation + +public extension CoreStrings { + static func getDomainLocalizedTitle(domain: Domain) -> String { + let key = "component::\(domain.rawValue)::title" + return Current.localized.core(key) ?? domain.rawValue + } + + static func getDomainStateLocalizedTitle(state: String) -> String? { + let key = "common::state::\(state)" + return Current.localized.core(key) + } +} diff --git a/Sources/Shared/Resources/Swiftgen/FrontendStrings+Values.swift b/Sources/Shared/Resources/Swiftgen/FrontendStrings+Values.swift new file mode 100644 index 000000000..f4b28e158 --- /dev/null +++ b/Sources/Shared/Resources/Swiftgen/FrontendStrings+Values.swift @@ -0,0 +1,16 @@ +// +// FrontendStrings+Values.swift +// App +// +// Created by Bruno Pantaleão on 04/01/2024. +// Copyright © 2024 Home Assistant. All rights reserved. +// + +import Foundation + +public extension FrontendStrings { + static func getDefaultStateLocalizedTitle(state: String) -> String? { + let key = "state::default::\(state)" + return Current.localized.frontend(key) + } +} diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Vehicle/Extensions/HAEntityExtension.swift index d208bde2e..837559f62 100644 --- a/Sources/Vehicle/Extensions/HAEntityExtension.swift +++ b/Sources/Vehicle/Extensions/HAEntityExtension.swift @@ -181,25 +181,6 @@ extension HAEntity { } var localizedState: String { - switch state { - case "closed": return L10n.State.closed - case "closing": return L10n.State.closing - case "jammed": return L10n.State.jammed - case "locked": return L10n.State.locked - case "locking": return L10n.State.locking - case "off": return L10n.State.off - case "on": return L10n.State.on - case "open": return L10n.State.open - case "opening": return L10n.State.opening - case "unavailable": return L10n.State.unavailable - case "unlocked": return L10n.State.unlocked - case "unlocking": return L10n.State.unlocking - case "unknown": return L10n.State.unknown - default: - let formatter = DateComponentsFormatter() - formatter.zeroFormattingBehavior = .pad - formatter.allowedUnits = [.hour, .minute, .second] - return formatter.string(from: lastChanged, to: Date()) ?? state - } + CoreStrings.getDomainStateLocalizedTitle(state: state) ?? FrontendStrings.getDefaultStateLocalizedTitle(state: state) ?? state } } From 58e6a4434632159245c56cee0447a2f16a300edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 12:57:04 +0100 Subject: [PATCH 22/41] Remove unused strings --- .../Resources/en.lproj/Localizable.strings | 36 --------- .../Shared/Resources/Swiftgen/Strings.swift | 75 ------------------- 2 files changed, 111 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 2651374ef..84955b027 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -788,43 +788,7 @@ Home Assistant is free and open source home automation software with a focus on "yes_label" = "Yes"; "carplay.navigation.button.next" = "Next"; "carplay.navigation.button.previous" = "Previous"; -"carplay.labels.buttons" = "Buttons"; -"carplay.labels.covers" = "Covers"; -"carplay.labels.input_booleans" = "Input Booleans"; -"carplay.labels.input_buttons" = "Input Buttons"; -"carplay.labels.lights" = "Lights"; -"carplay.labels.locks" = "Locks"; -"carplay.labels.scenes" = "Scenes"; -"carplay.labels.scripts" = "Scripts"; -"carplay.labels.switches" = "Switches"; "carplay.labels.servers" = "Servers"; "carplay.labels.empty_domain_list" = "No domains available"; "carplay.labels.no_servers_available" = "No servers available. Add a server in the app."; "carplay.labels.already_added_server" = "Already added"; -"state.auto" = "Auto"; -"state.cleaning" = "Cleaning"; -"state.closed" = "Closed"; -"state.closing" = "Closing"; -"state.cool" = "Cool"; -"state.docked" = "Docked"; -"state.dry" = "Dry"; -"state.error" = "Error"; -"state.fan_only" = "Fan Only"; -"state.heat_cool" = "Heat Cool"; -"state.heat" = "Heat"; -"state.idle" = "Idle"; -"state.jammed" = "Jammed"; -"state.locked" = "Locked"; -"state.locking"= "Locking"; -"state.off" = "Off"; -"state.on" = "On"; -"state.open" = "Open"; -"state.opening" = "Opening"; -"state.paused" = "Paused"; -"state.returning" = "Returning"; -"state.unavailable" = "Unavailable"; -"state.unknown" = "Unknown"; -"state.unlocked" = "Unlocked"; -"state.unlocking" = "Unlocking"; -"state.recording" = "Recording"; -"state.streaming" = "Streaming"; diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 3cbabff74..2a3332fbf 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -235,30 +235,12 @@ public enum L10n { public enum Labels { /// Already added public static var alreadyAddedServer: String { return L10n.tr("Localizable", "carplay.labels.already_added_server") } - /// Buttons - public static var buttons: String { return L10n.tr("Localizable", "carplay.labels.buttons") } - /// Covers - public static var covers: String { return L10n.tr("Localizable", "carplay.labels.covers") } /// No domains available public static var emptyDomainList: String { return L10n.tr("Localizable", "carplay.labels.empty_domain_list") } - /// Input Booleans - public static var inputBooleans: String { return L10n.tr("Localizable", "carplay.labels.input_booleans") } - /// Input Buttons - public static var inputButtons: String { return L10n.tr("Localizable", "carplay.labels.input_buttons") } - /// Lights - public static var lights: String { return L10n.tr("Localizable", "carplay.labels.lights") } - /// Locks - public static var locks: String { return L10n.tr("Localizable", "carplay.labels.locks") } /// No servers available. Add a server in the app. public static var noServersAvailable: String { return L10n.tr("Localizable", "carplay.labels.no_servers_available") } - /// Scenes - public static var scenes: String { return L10n.tr("Localizable", "carplay.labels.scenes") } - /// Scripts - public static var scripts: String { return L10n.tr("Localizable", "carplay.labels.scripts") } /// Servers public static var servers: String { return L10n.tr("Localizable", "carplay.labels.servers") } - /// Switches - public static var switches: String { return L10n.tr("Localizable", "carplay.labels.switches") } } public enum Navigation { public enum Button { @@ -1823,63 +1805,6 @@ public enum L10n { } } - public enum State { - /// Auto - public static var auto: String { return L10n.tr("Localizable", "state.auto") } - /// Cleaning - public static var cleaning: String { return L10n.tr("Localizable", "state.cleaning") } - /// Closed - public static var closed: String { return L10n.tr("Localizable", "state.closed") } - /// Closing - public static var closing: String { return L10n.tr("Localizable", "state.closing") } - /// Cool - public static var cool: String { return L10n.tr("Localizable", "state.cool") } - /// Docked - public static var docked: String { return L10n.tr("Localizable", "state.docked") } - /// Dry - public static var dry: String { return L10n.tr("Localizable", "state.dry") } - /// Error - public static var error: String { return L10n.tr("Localizable", "state.error") } - /// Fan Only - public static var fanOnly: String { return L10n.tr("Localizable", "state.fan_only") } - /// Heat - public static var heat: String { return L10n.tr("Localizable", "state.heat") } - /// Heat Cool - public static var heatCool: String { return L10n.tr("Localizable", "state.heat_cool") } - /// Idle - public static var idle: String { return L10n.tr("Localizable", "state.idle") } - /// Jammed - public static var jammed: String { return L10n.tr("Localizable", "state.jammed") } - /// Locked - public static var locked: String { return L10n.tr("Localizable", "state.locked") } - /// Locking - public static var locking: String { return L10n.tr("Localizable", "state.locking") } - /// Off - public static var off: String { return L10n.tr("Localizable", "state.off") } - /// On - public static var on: String { return L10n.tr("Localizable", "state.on") } - /// Open - public static var `open`: String { return L10n.tr("Localizable", "state.open") } - /// Opening - public static var opening: String { return L10n.tr("Localizable", "state.opening") } - /// Paused - public static var paused: String { return L10n.tr("Localizable", "state.paused") } - /// Recording - public static var recording: String { return L10n.tr("Localizable", "state.recording") } - /// Returning - public static var returning: String { return L10n.tr("Localizable", "state.returning") } - /// Streaming - public static var streaming: String { return L10n.tr("Localizable", "state.streaming") } - /// Unavailable - public static var unavailable: String { return L10n.tr("Localizable", "state.unavailable") } - /// Unknown - public static var unknown: String { return L10n.tr("Localizable", "state.unknown") } - /// Unlocked - public static var unlocked: String { return L10n.tr("Localizable", "state.unlocked") } - /// Unlocking - public static var unlocking: String { return L10n.tr("Localizable", "state.unlocking") } - } - public enum Thread { public enum Credentials { /// Border Agent ID From f57496067dbf99987481dd3e310f19f42c335391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 13:31:17 +0100 Subject: [PATCH 23/41] Improve icon logic --- HomeAssistant.xcodeproj/project.pbxproj | 12 ++++++---- .../Common/Extensions/HAEntity+CarPlay.swift} | 23 ++++++------------- 2 files changed, 15 insertions(+), 20 deletions(-) rename Sources/{Vehicle/Extensions/HAEntityExtension.swift => Shared/Common/Extensions/HAEntity+CarPlay.swift} (90%) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 96ae61f59..f2e1aa332 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -533,6 +533,9 @@ 42CE8FB32B46C46F00C707F9 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAC2B46C12C00C707F9 /* Domain.swift */; }; 42CE8FB62B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */; }; 42CE8FB72B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */; }; + 42CE8FB92B46D67A00C707F9 /* HAEntity+CarPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66629BA003B00B19FBE /* HAEntity+CarPlay.swift */; }; + 42CE8FBA2B46D67A00C707F9 /* HAEntity+CarPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66629BA003B00B19FBE /* HAEntity+CarPlay.swift */; }; + 42CE8FBB2B46DB6200C707F9 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65B15042273188300635D5C /* Assets.swift */; }; 42DD84132B14ACAB00936F16 /* Color+ColorAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */; }; 42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84142B14D68C00936F16 /* WebViewExternalBusMessage.swift */; }; 42DD84192B14D83B00936F16 /* WebViewExternalBusMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84182B14D83B00936F16 /* WebViewExternalBusMessageTests.swift */; }; @@ -912,7 +915,6 @@ D0FF79D520D87DB10034574D /* ClientEvents.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D0FF79D420D87DB10034574D /* ClientEvents.storyboard */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; - FD3BC66729BA003B00B19FBE /* HAEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */; }; FD3BC66C29BA00D600B19FBE /* EntitiesListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* EntitiesListTemplate.swift */; }; FD3BC66E29BA010A00B19FBE /* DomainsListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */; }; FD5FEB304713F1E6BFE498DC /* Pods_iOS_Extensions_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE950A9D74B3E7FF5665CB38 /* Pods_iOS_Extensions_NotificationService.framework */; }; @@ -2163,7 +2165,7 @@ F3E55AA06795782F04D0B261 /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; }; F534C18A6FD4884F258341C9 /* Pods-iOS-Shared-iOS.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.beta.xcconfig"; sourceTree = ""; }; FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; - FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAEntityExtension.swift; sourceTree = ""; }; + FD3BC66629BA003B00B19FBE /* HAEntity+CarPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HAEntity+CarPlay.swift"; sourceTree = ""; }; FD3BC66B29BA00D600B19FBE /* EntitiesListTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitiesListTemplate.swift; sourceTree = ""; }; FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsListTemplate.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -3884,6 +3886,7 @@ D0A6367320DBE91300E5C49B /* Extensions */ = { isa = PBXGroup; children = ( + FD3BC66629BA003B00B19FBE /* HAEntity+CarPlay.swift */, B6DF8BC0221C890600370A59 /* UIImageView+UIActivityIndicator.swift */, B6B6B14B215B1E86003DE2DD /* CLKComplication+Strings.swift */, 114E9B4D24E89B1300B43EED /* INImage+MaterialDesignIcons.swift */, @@ -4058,7 +4061,6 @@ FD3BC66529BA001A00B19FBE /* Extensions */ = { isa = PBXGroup; children = ( - FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -5803,7 +5805,6 @@ 42F5CABC2B10AE1A00409816 /* ServerFixture.swift in Sources */, 11B1FFC524CCD72F00F9BCB2 /* VoiceShortcutRow.swift in Sources */, 1168BF33271809C600DD4D15 /* OnboardingAuthError.swift in Sources */, - FD3BC66729BA003B00B19FBE /* HAEntityExtension.swift in Sources */, B661FB6F226BCCAD00E541DD /* ConnectionSettingsViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5910,6 +5911,7 @@ 11AF4D13249C7E08006C74C0 /* ActivitySensor.swift in Sources */, 11E5CF8224BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift in Sources */, 11AF4D1D249C8AA0006C74C0 /* BatterySensor.swift in Sources */, + 42CE8FBA2B46D67A00C707F9 /* HAEntity+CarPlay.swift in Sources */, B67CE8A922200F220034C1D0 /* SettingsStore.swift in Sources */, 11AF4D26249D1931006C74C0 /* LastUpdateSensor.swift in Sources */, 11EE9B4A24C5116F00404AF8 /* ModelManager.swift in Sources */, @@ -5982,6 +5984,7 @@ 11E1639B250B1B760076D612 /* OnboardingStateObservation.swift in Sources */, 115BC8292676F44E00452430 /* FocusSensor.swift in Sources */, B6221F6522266F9F00502A30 /* WebhookRequest.swift in Sources */, + 42CE8FBB2B46DB6200C707F9 /* Assets.swift in Sources */, 11B38EF4275C54A300205C7B /* WidgetActionsIntentHandler.swift in Sources */, 11F3847C24FB27FC00CB0D74 /* DeviceWrapperBatteryObserver.swift in Sources */, 11B38EFA275C54A300205C7B /* FocusStatusIntentHandler.swift in Sources */, @@ -6094,6 +6097,7 @@ 42CE8FA92B45D1E900C707F9 /* FrontendStrings.swift in Sources */, D0EEF335214EB77100D1D360 /* CLLocation+Extensions.swift in Sources */, 11AF4D1F249C8AF1006C74C0 /* ConnectivitySensor.swift in Sources */, + 42CE8FB92B46D67A00C707F9 /* HAEntity+CarPlay.swift in Sources */, 11B38EED275C54A200205C7B /* RenderTemplateIntentHandler.swift in Sources */, B6723341225DB82E0031D629 /* KeyedDecodingContainer+JSON.swift in Sources */, 11C4629124B14E6B00031902 /* XCGLogger+UNNotification.swift in Sources */, diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift similarity index 90% rename from Sources/Vehicle/Extensions/HAEntityExtension.swift rename to Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index 837559f62..c0f748b40 100644 --- a/Sources/Vehicle/Extensions/HAEntityExtension.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -1,11 +1,11 @@ import Foundation import HAKit import PromiseKit -import Shared import SwiftUI +import UIKit -extension HAEntity { - public static func icon(forDomain: String, size: CGSize) -> UIImage? { +public extension HAEntity { + static func icon(forDomain: String, size: CGSize) -> UIImage? { do { let tmpEntity = try HAEntity( entityId: "\(forDomain).ha_ios_placeholder", @@ -40,15 +40,11 @@ extension HAEntity { return api.CallService(domain: domain, service: service, serviceData: ["entity_id": entityId]) } - func getIcon(size: CGSize = CGSize(width: 64, height: 64), darkColor: UIColor = UIColor.white) -> UIImage? { - let icon = attributes.icon ?? "" - + func getIcon(size: CGSize = CGSize(width: 64, height: 64)) -> UIImage? { var image = MaterialDesignIcons.bookmarkIcon - if icon.starts(with: "mdi:") { - let mdiIcon = icon.components(separatedBy: ":")[1] - let iconName = mdiIcon.replacingOccurrences(of: "-", with: "_") - image = MaterialDesignIcons(named: iconName) + if let icon = attributes.icon?.normalizingIconString { + image = MaterialDesignIcons(named: icon) } else { let compareState = state switch domain { @@ -118,12 +114,7 @@ extension HAEntity { image = MaterialDesignIcons.bookmarkIcon } } - let iconImage = image.image(ofSize: size, color: nil) - iconImage.imageAsset?.register( - image.image(ofSize: size, color: darkColor), - with: .init(userInterfaceStyle: .dark) - ) - return iconImage + return image.image(ofSize: size, color: .white) } private func getCoverIcon() -> MaterialDesignIcons { From 2840b76625630b3b3aa4430695c41f68a538dfca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 13:47:28 +0100 Subject: [PATCH 24/41] Improve domain icon --- .../Common/Extensions/HAEntity+CarPlay.swift | 16 ------------ Sources/Shared/Domain/Domain.swift | 26 +++++++++++++++++++ .../Templates/DomainsListTemplate.swift | 10 +++---- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index c0f748b40..c78e33bad 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -5,22 +5,6 @@ import SwiftUI import UIKit public extension HAEntity { - static func icon(forDomain: String, size: CGSize) -> UIImage? { - do { - let tmpEntity = try HAEntity( - entityId: "\(forDomain).ha_ios_placeholder", - domain: forDomain, - state: "", - lastChanged: Date(), - lastUpdated: Date(), - attributes: [:], - context: HAResponseEvent.Context(id: "", userId: nil, parentId: nil) - ) - return tmpEntity.getIcon(size: size) - } catch { - return nil - } - } func onPress(for api: HomeAssistantAPI) -> Promise { let domain = domain diff --git a/Sources/Shared/Domain/Domain.swift b/Sources/Shared/Domain/Domain.swift index 9a00d8b11..73e15c946 100644 --- a/Sources/Shared/Domain/Domain.swift +++ b/Sources/Shared/Domain/Domain.swift @@ -7,6 +7,7 @@ // import Foundation +import UIKit public enum Domain: String, CaseIterable { case button @@ -33,6 +34,31 @@ public enum Domain: String, CaseIterable { ] } + public var icon: UIImage { + var image = MaterialDesignIcons.bookmarkIcon + switch self { + case .button: + image = MaterialDesignIcons.gestureTapButtonIcon + case .cover: + image = MaterialDesignIcons.curtainsIcon + case .input_boolean: + image = MaterialDesignIcons.toggleSwitchOutlineIcon + case .input_button: + image = MaterialDesignIcons.gestureTapButtonIcon + case .light: + image = MaterialDesignIcons.lightbulbIcon + case .lock: + image = MaterialDesignIcons.lockIcon + case .scene: + image = MaterialDesignIcons.paletteOutlineIcon + case .script: + image = MaterialDesignIcons.scriptTextOutlineIcon + case .switch: + image = MaterialDesignIcons.lightSwitchIcon + } + return image.image(ofSize: .init(width: 64, height: 64), color: .white) + } + public var localizedDescription: String { CoreStrings.getDomainLocalizedTitle(domain: self) } diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 2299f28a1..4baaef353 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -55,18 +55,16 @@ class DomainsListTemplate { })) domains.forEach { domain in - let itemTitle = Domain(rawValue: domain)?.localizedDescription ?? domain + guard let domain = Domain(rawValue: domain) else { return } + let itemTitle = domain.localizedDescription let listItem = CPListItem( text: itemTitle, detailText: nil, - image: HAEntity.icon( - forDomain: domain, - size: CPListItem.maximumImageSize - ) + image: domain.icon ) listItem.accessoryType = CPListItemAccessoryType.disclosureIndicator listItem.handler = { [weak self] _, completion in - self?.listItemHandler(domain: domain) + self?.listItemHandler(domain: domain.rawValue) completion() } From cdddfe1cd5d45ab104f810a372b8a51bc11d2eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 14:00:47 +0100 Subject: [PATCH 25/41] WIP Add color for lights on --- Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index c78e33bad..a14d92850 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -26,6 +26,7 @@ public extension HAEntity { func getIcon(size: CGSize = CGSize(width: 64, height: 64)) -> UIImage? { var image = MaterialDesignIcons.bookmarkIcon + var tint: UIColor = .white if let icon = attributes.icon?.normalizingIconString { image = MaterialDesignIcons(named: icon) @@ -98,7 +99,13 @@ public extension HAEntity { image = MaterialDesignIcons.bookmarkIcon } } - return image.image(ofSize: size, color: .white) + + // TODO: Improve logic and create enum for states + if state == "on" { + tint = .yellow + } + + return image.image(ofSize: size, color: tint) } private func getCoverIcon() -> MaterialDesignIcons { From a36419a090f1d0919e6f4da8eff9e205f167d69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 14:04:14 +0100 Subject: [PATCH 26/41] Use type safe domain --- .../Common/Extensions/HAEntity+CarPlay.swift | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index a14d92850..208aaa9e2 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -32,8 +32,9 @@ public extension HAEntity { image = MaterialDesignIcons(named: icon) } else { let compareState = state + guard let domain = Domain(rawValue: domain) else { return nil } switch domain { - case "button": + case .button: guard let deviceClass = attributes.dictionary["device_class"] as? String else { break } if deviceClass == "restart" { image = MaterialDesignIcons.restartIcon @@ -42,9 +43,9 @@ public extension HAEntity { } else { image = MaterialDesignIcons.gestureTapButtonIcon } - case "cover": + case .cover: image = getCoverIcon() - case "input_boolean": + case .input_boolean: if !entityId.hasSuffix(".ha_ios_placeholder") { if compareState == "on" { image = MaterialDesignIcons.checkCircleOutlineIcon @@ -54,11 +55,11 @@ public extension HAEntity { } else { image = MaterialDesignIcons.toggleSwitchOutlineIcon } - case "input_button": + case .input_button: image = MaterialDesignIcons.gestureTapButtonIcon - case "light": + case .light: image = MaterialDesignIcons.lightbulbIcon - case "lock": + case .lock: switch compareState { case "unlocked": image = MaterialDesignIcons.lockOpenIcon @@ -69,15 +70,11 @@ public extension HAEntity { default: image = MaterialDesignIcons.lockIcon } - case "person": - image = MaterialDesignIcons.accountIcon - case "scene": + case .scene: image = MaterialDesignIcons.paletteOutlineIcon - case "script": + case .script: image = MaterialDesignIcons.scriptTextOutlineIcon - case "sensor": - image = MaterialDesignIcons.eyeIcon - case "switch": + case .switch: if !entityId.hasSuffix(".ha_ios_placeholder") { let deviceClass = attributes.dictionary["device_class"] as? String switch deviceClass { @@ -93,10 +90,6 @@ public extension HAEntity { } else { image = MaterialDesignIcons.lightSwitchIcon } - case "zone": - image = MaterialDesignIcons.mapMarkerRadiusIcon - default: - image = MaterialDesignIcons.bookmarkIcon } } @@ -113,6 +106,7 @@ public extension HAEntity { let state = state let open = state != "closed" + // TODO: Make enum for entity types switch device_class { case "garage": switch state { From 303df7bfbabe0bbbc4a4a1f5cf32b1acec783b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 14:48:33 +0100 Subject: [PATCH 27/41] Pass to entities list just it's domain cache --- .../Vehicle/Templates/DomainsListTemplate.swift | 6 +++--- .../Vehicle/Templates/EntitiesListTemplate.swift | 16 +++++----------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 4baaef353..8d15ce68d 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -76,12 +76,12 @@ class DomainsListTemplate { } private func listItemHandler(domain: String) { - let itemTitle = domain let entitiesGridTemplate = EntitiesListTemplate( - title: itemTitle, domain: domain, server: server, - entitiesCachedStates: entitiesCachedStates + entitiesCachedStates: entitiesCachedStates.map({ cachedStates in + cachedStates.all.filter({ $0.domain == domain }) + }) ) interfaceController?.pushTemplate( diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift index dc682d8f4..6b482b7ab 100644 --- a/Sources/Vehicle/Templates/EntitiesListTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -8,10 +8,9 @@ import Shared class EntitiesListTemplate { private let entityIconSize: CGSize = .init(width: 64, height: 64) private var stateSubscriptionToken: HACancellable? - private let title: String private let domain: String private var server: Server - private let entitiesCachedStates: HACache + private let entitiesCachedStates: HACache> private var listTemplate: CPListTemplate? private var currentPage: Int = 0 @@ -20,8 +19,7 @@ class EntitiesListTemplate { private var entitiesSubscriptionToken: HACancellable? - init(title: String, domain: String, server: Server, entitiesCachedStates: HACache) { - self.title = title + init(domain: String, server: Server, entitiesCachedStates: HACache>) { self.domain = domain self.server = server self.entitiesCachedStates = entitiesCachedStates @@ -38,18 +36,14 @@ class EntitiesListTemplate { if let listTemplate = listTemplate { return listTemplate } else { - listTemplate = CPListTemplate(title: title, sections: []) + listTemplate = CPListTemplate(title: "", sections: []) return listTemplate! } } private func updateListItems() { - let entities = entitiesCachedStates.value?.all.filter { $0.domain == domain } - - let entitiesSorted = entities? - .sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) - - guard let entitiesSorted else { return } + guard let entities = entitiesCachedStates.value else { return } + let entitiesSorted = entities.sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) let startIndex = currentPage * itemsPerPage let endIndex = min(startIndex + itemsPerPage, entitiesSorted.count) From 779b9cc9b911cf82ca3277f77e5c7e25c6775f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 14:54:41 +0100 Subject: [PATCH 28/41] Fix domain sorting --- Sources/Vehicle/Templates/DomainsListTemplate.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 8d15ce68d..835151f35 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -10,7 +10,7 @@ class DomainsListTemplate { private let serverButtonHandler: CPBarButtonHandler? private let server: Server - private var domainList: Set = [] + private var domainList: [String] = [] private var listTemplate: CPListTemplate? weak var interfaceController: CPInterfaceController? @@ -48,11 +48,10 @@ class DomainsListTemplate { func updateSections() { var items: [CPListItem] = [] - var domains = Set(entitiesCachedStates.value?.all.map(\.domain) ?? []) - domains = domains.filter { Domain(rawValue: $0)?.isCarPlaySupported ?? false } - domains = Set(domains.sorted(by: { d1, d2 in + let entityDomains = Set(entitiesCachedStates.value?.all.map(\.domain) ?? []) + let domains = entityDomains.filter { Domain(rawValue: $0)?.isCarPlaySupported ?? false }.sorted(by: { d1, d2 in d1 < d2 - })) + }) domains.forEach { domain in guard let domain = Domain(rawValue: domain) else { return } From 7640f7f36a228971e6bf9986acfc175cbe03fd07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 15:21:16 +0100 Subject: [PATCH 29/41] Make states type safe --- .../Common/Extensions/HAEntity+CarPlay.swift | 60 +++++++------- Sources/Shared/Domain/Domain.swift | 82 +++++++++++++++---- 2 files changed, 96 insertions(+), 46 deletions(-) diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index 208aaa9e2..4334c935a 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -31,7 +31,7 @@ public extension HAEntity { if let icon = attributes.icon?.normalizingIconString { image = MaterialDesignIcons(named: icon) } else { - let compareState = state + guard let compareState = Domain.State(rawValue: state) else { return nil } guard let domain = Domain(rawValue: domain) else { return nil } switch domain { case .button: @@ -45,9 +45,9 @@ public extension HAEntity { } case .cover: image = getCoverIcon() - case .input_boolean: + case .inputBoolean: if !entityId.hasSuffix(".ha_ios_placeholder") { - if compareState == "on" { + if compareState == .on { image = MaterialDesignIcons.checkCircleOutlineIcon } else { image = MaterialDesignIcons.closeCircleOutlineIcon @@ -55,17 +55,17 @@ public extension HAEntity { } else { image = MaterialDesignIcons.toggleSwitchOutlineIcon } - case .input_button: + case .inputButton: image = MaterialDesignIcons.gestureTapButtonIcon case .light: image = MaterialDesignIcons.lightbulbIcon case .lock: switch compareState { - case "unlocked": + case .unlocked: image = MaterialDesignIcons.lockOpenIcon - case "jammed": + case .jammed: image = MaterialDesignIcons.lockAlertIcon - case "locking", "unlocking": + case .locking, .unlocking: image = MaterialDesignIcons.lockClockIcon default: image = MaterialDesignIcons.lockIcon @@ -79,10 +79,10 @@ public extension HAEntity { let deviceClass = attributes.dictionary["device_class"] as? String switch deviceClass { case "outlet": - image = compareState == "on" ? MaterialDesignIcons.powerPlugIcon : MaterialDesignIcons + image = compareState == .on ? MaterialDesignIcons.powerPlugIcon : MaterialDesignIcons .powerPlugOffIcon case "switch": - image = compareState == "on" ? MaterialDesignIcons.toggleSwitchIcon : MaterialDesignIcons + image = compareState == .on ? MaterialDesignIcons.toggleSwitchIcon : MaterialDesignIcons .toggleSwitchOffIcon default: image = MaterialDesignIcons.flashIcon @@ -104,53 +104,53 @@ public extension HAEntity { private func getCoverIcon() -> MaterialDesignIcons { let device_class = attributes.dictionary["device_class"] as? String let state = state - let open = state != "closed" - // TODO: Make enum for entity types + guard let state = Domain.State(rawValue: state) else { return MaterialDesignIcons.bookmarkIcon } + switch device_class { case "garage": switch state { - case "opening": return MaterialDesignIcons.arrowUpBoxIcon - case "closing": return MaterialDesignIcons.arrowDownBoxIcon - case "closed": return MaterialDesignIcons.garageIcon + case .opening: return MaterialDesignIcons.arrowUpBoxIcon + case .closing: return MaterialDesignIcons.arrowDownBoxIcon + case .closed: return MaterialDesignIcons.garageIcon default: return MaterialDesignIcons.garageOpenIcon } case "gate": switch state { - case "opening", "closing": return MaterialDesignIcons.gateArrowRightIcon - case "closed": return MaterialDesignIcons.gateIcon + case .opening: return MaterialDesignIcons.gateArrowRightIcon + case .closed: return MaterialDesignIcons.gateIcon default: return MaterialDesignIcons.gateOpenIcon } case "door": - return open ? MaterialDesignIcons.doorOpenIcon : MaterialDesignIcons.doorClosedIcon + return state == .open ? MaterialDesignIcons.doorOpenIcon : MaterialDesignIcons.doorClosedIcon case "damper": - return open ? MaterialDesignIcons.circleIcon : MaterialDesignIcons.circleSlice8Icon + return state == .open ? MaterialDesignIcons.circleIcon : MaterialDesignIcons.circleSlice8Icon case "shutter": switch state { - case "opening": return MaterialDesignIcons.arrowUpBoxIcon - case "closing": return MaterialDesignIcons.arrowDownBoxIcon - case "closed": return MaterialDesignIcons.windowShutterIcon + case .opening: return MaterialDesignIcons.arrowUpBoxIcon + case .closing: return MaterialDesignIcons.arrowDownBoxIcon + case .closed: return MaterialDesignIcons.windowShutterIcon default: return MaterialDesignIcons.windowShutterOpenIcon } case "curtain": switch state { - case "opening": return MaterialDesignIcons.arrowSplitVerticalIcon - case "closing": return MaterialDesignIcons.arrowCollapseHorizontalIcon - case "closed": return MaterialDesignIcons.curtainsClosedIcon + case .opening: return MaterialDesignIcons.arrowSplitVerticalIcon + case .closing: return MaterialDesignIcons.arrowCollapseHorizontalIcon + case .closed: return MaterialDesignIcons.curtainsClosedIcon default: return MaterialDesignIcons.curtainsIcon } case "blind", "shade": switch state { - case "opening": return MaterialDesignIcons.arrowUpBoxIcon - case "closing": return MaterialDesignIcons.arrowDownBoxIcon - case "closed": return MaterialDesignIcons.blindsIcon + case .opening: return MaterialDesignIcons.arrowUpBoxIcon + case .closing: return MaterialDesignIcons.arrowDownBoxIcon + case .closed: return MaterialDesignIcons.blindsIcon default: return MaterialDesignIcons.blindsOpenIcon } default: switch state { - case "opening": return MaterialDesignIcons.arrowUpBoxIcon - case "closing": return MaterialDesignIcons.arrowDownBoxIcon - case "closed": return MaterialDesignIcons.windowClosedIcon + case .open: return MaterialDesignIcons.arrowUpBoxIcon + case .closing: return MaterialDesignIcons.arrowDownBoxIcon + case .closed: return MaterialDesignIcons.windowClosedIcon default: return MaterialDesignIcons.windowOpenIcon } } diff --git a/Sources/Shared/Domain/Domain.swift b/Sources/Shared/Domain/Domain.swift index 73e15c946..8a460ade8 100644 --- a/Sources/Shared/Domain/Domain.swift +++ b/Sources/Shared/Domain/Domain.swift @@ -12,26 +12,59 @@ import UIKit public enum Domain: String, CaseIterable { case button case cover - case input_boolean - case input_button + case inputBoolean = "input_bollean" + case inputButton = "input_button" case light case lock case scene case script case `switch` + // TODO: Map more domains - public var carPlaySupportedDomains: [Domain] { - [ - .button, - .cover, - .input_boolean, - .input_button, - .light, - .lock, - .scene, - .script, - .switch - ] + public enum State: String { + case locked + case unlocked + case jammed + case locking + case unlocking + + case on + case off + + case opening + case closing + case closed + case open + + case unknown + case unavailable + } + + public var states: [State] { + var states: [State] = [] + switch self { + case .button: + states = [] + case .cover: + states = [.open, .closed, .opening, .closing] + case .inputBoolean: + states = [] + case .inputButton: + states = [] + case .light: + states = [.on, .off] + case .lock: + states = [.locked, .unlocked, .jammed, .locking, .unlocking] + case .scene: + states = [] + case .script: + states = [] + case .switch: + states = [.on, .off] + } + + states.append(contentsOf: [.unavailable, .unknown]) + return states } public var icon: UIImage { @@ -41,9 +74,9 @@ public enum Domain: String, CaseIterable { image = MaterialDesignIcons.gestureTapButtonIcon case .cover: image = MaterialDesignIcons.curtainsIcon - case .input_boolean: + case .inputBoolean: image = MaterialDesignIcons.toggleSwitchOutlineIcon - case .input_button: + case .inputButton: image = MaterialDesignIcons.gestureTapButtonIcon case .light: image = MaterialDesignIcons.lightbulbIcon @@ -67,3 +100,20 @@ public enum Domain: String, CaseIterable { carPlaySupportedDomains.contains(self) } } + +// MARK: - CarPlay +extension Domain { + public var carPlaySupportedDomains: [Domain] { + [ + .button, + .cover, + .inputBoolean, + .inputButton, + .light, + .lock, + .scene, + .script, + .switch + ] + } +} From 4049722516cada631bf9cb8f5ad16b8d2e6ff3f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 15:44:18 +0100 Subject: [PATCH 30/41] Lint --- .../Common/Extensions/HAEntity+CarPlay.swift | 4 ++-- Sources/Shared/Domain/Domain.swift | 15 ++++----------- .../Vehicle/Templates/EntitiesListTemplate.swift | 3 ++- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index 4334c935a..0c8800c1b 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -5,7 +5,6 @@ import SwiftUI import UIKit public extension HAEntity { - func onPress(for api: HomeAssistantAPI) -> Promise { let domain = domain var service: String @@ -157,6 +156,7 @@ public extension HAEntity { } var localizedState: String { - CoreStrings.getDomainStateLocalizedTitle(state: state) ?? FrontendStrings.getDefaultStateLocalizedTitle(state: state) ?? state + CoreStrings.getDomainStateLocalizedTitle(state: state) ?? FrontendStrings + .getDefaultStateLocalizedTitle(state: state) ?? state } } diff --git a/Sources/Shared/Domain/Domain.swift b/Sources/Shared/Domain/Domain.swift index 8a460ade8..a8e5127ee 100644 --- a/Sources/Shared/Domain/Domain.swift +++ b/Sources/Shared/Domain/Domain.swift @@ -1,11 +1,3 @@ -// -// Domains.swift -// App -// -// Created by Bruno Pantaleão on 04/01/2024. -// Copyright © 2024 Home Assistant. All rights reserved. -// - import Foundation import UIKit @@ -102,8 +94,9 @@ public enum Domain: String, CaseIterable { } // MARK: - CarPlay -extension Domain { - public var carPlaySupportedDomains: [Domain] { + +public extension Domain { + var carPlaySupportedDomains: [Domain] { [ .button, .cover, @@ -113,7 +106,7 @@ extension Domain { .lock, .scene, .script, - .switch + .switch, ] } } diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift index 6b482b7ab..d93249c8c 100644 --- a/Sources/Vehicle/Templates/EntitiesListTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -43,7 +43,8 @@ class EntitiesListTemplate { private func updateListItems() { guard let entities = entitiesCachedStates.value else { return } - let entitiesSorted = entities.sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) + let entitiesSorted = entities + .sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) let startIndex = currentPage * itemsPerPage let endIndex = min(startIndex + itemsPerPage, entitiesSorted.count) From ceb9beb2e6bd4d3c250bcef51f44b4716dac831c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 16:22:30 +0100 Subject: [PATCH 31/41] Add typed request for tap action --- HomeAssistant.xcodeproj/project.pbxproj | 14 +++ .../CarPlay/HATypedRequest+CarPlay.swift | 89 +++++++++++++++++++ .../Common/Extensions/HAEntity+CarPlay.swift | 55 +++++++++--- 3 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index f2e1aa332..e4ebedc50 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -539,6 +539,8 @@ 42DD84132B14ACAB00936F16 /* Color+ColorAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */; }; 42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84142B14D68C00936F16 /* WebViewExternalBusMessage.swift */; }; 42DD84192B14D83B00936F16 /* WebViewExternalBusMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84182B14D83B00936F16 /* WebViewExternalBusMessageTests.swift */; }; + 42F1DA582B46FDD8002729BC /* HATypedRequest+CarPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1DA572B46FDD8002729BC /* HATypedRequest+CarPlay.swift */; }; + 42F1DA592B46FDD8002729BC /* HATypedRequest+CarPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1DA572B46FDD8002729BC /* HATypedRequest+CarPlay.swift */; }; 42F5CAB92B10AD9800409816 /* ThreadCredentialsSharingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F5CAB82B10AD9800409816 /* ThreadCredentialsSharingViewModelTests.swift */; }; 42F5CABC2B10AE1A00409816 /* ServerFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F5CABB2B10AE1A00409816 /* ServerFixture.swift */; }; 42F5CAE52B10CDC600409816 /* HACornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CA28AD2B101D4D0093B31A /* HACornerRadius.swift */; }; @@ -1702,6 +1704,7 @@ 42DD84372B15DC3F00936F16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Frontend.strings; sourceTree = ""; }; 42DD84382B15DC3F00936F16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/InfoPlist.strings; sourceTree = ""; }; 42DD84392B15DC3F00936F16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = ""; }; + 42F1DA572B46FDD8002729BC /* HATypedRequest+CarPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HATypedRequest+CarPlay.swift"; sourceTree = ""; }; 42F5CAB82B10AD9800409816 /* ThreadCredentialsSharingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadCredentialsSharingViewModelTests.swift; sourceTree = ""; }; 42F5CABB2B10AE1A00409816 /* ServerFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFixture.swift; sourceTree = ""; }; 42F5CADF2B10CD2D00409816 /* ThreadCredentialsSharing+build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadCredentialsSharing+build.swift"; sourceTree = ""; }; @@ -3159,6 +3162,14 @@ path = Tests; sourceTree = ""; }; + 42F1DA562B46FDC5002729BC /* CarPlay */ = { + isa = PBXGroup; + children = ( + 42F1DA572B46FDD8002729BC /* HATypedRequest+CarPlay.swift */, + ); + path = CarPlay; + sourceTree = ""; + }; 42F5CAB72B10AD8C00409816 /* Tests */ = { isa = PBXGroup; children = ( @@ -3816,6 +3827,7 @@ isa = PBXGroup; children = ( 426740A42B17348700C1DD73 /* Assets */, + 42F1DA562B46FDC5002729BC /* CarPlay */, 42CE8FAB2B46C11E00C707F9 /* Domain */, 42CA28AC2B101D320093B31A /* DesignSystem */, 11B38EE0275C545C00205C7B /* Intents */, @@ -5906,6 +5918,7 @@ 110ED59025A6743900489AF7 /* ConnectivityWrapper.swift in Sources */, 1110836924AFEFA60027A67A /* Promise+WebhookJson.swift in Sources */, 1164D9DF25FB1B9800515E8A /* UIBarButtonItem+Additions.swift in Sources */, + 42F1DA592B46FDD8002729BC /* HATypedRequest+CarPlay.swift in Sources */, 11B38EF6275C54A300205C7B /* PickAServerError.swift in Sources */, B67CE8AF22200F220034C1D0 /* ObjectMapperTransformers.swift in Sources */, 11AF4D13249C7E08006C74C0 /* ActivitySensor.swift in Sources */, @@ -6200,6 +6213,7 @@ 11C4628E24B128EF00031902 /* WebhookResponseUnhandled.swift in Sources */, 1121CD4C271295AD0071C2AA /* Style.swift in Sources */, 116570772702B0F6003906A7 /* DiskCache.swift in Sources */, + 42F1DA582B46FDD8002729BC /* HATypedRequest+CarPlay.swift in Sources */, 11657050270188E4003906A7 /* URLComponents+WidgetAuthenticity.swift in Sources */, B672334A225DDF410031D629 /* Event.swift in Sources */, B6C091232151F90300A326DC /* LocationHistory.swift in Sources */, diff --git a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift new file mode 100644 index 000000000..3bbd43031 --- /dev/null +++ b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift @@ -0,0 +1,89 @@ +import Foundation +import HAKit + +extension HATypedRequest { + static func toggleDomain( + domain: Domain, + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "domain": domain.rawValue, + "service": "toggle", + "service_data": [ + "entity_id": entityId, + ], + ] + )) + } + + static func runScript( + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "service": "script.\(entityId)", + ] + )) + } + + static func applyScene( + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "service": "scene.apply", + "service_data": [ + "entities": [ + entityId, + ], + ], + ] + )) + } + + static func pressButton( + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "service": "button.press", + "service_data": [ + "entityId": entityId, + ], + ] + )) + } + + static func lockLock( + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "service": "lock.lock", + "service_data": [ + "entityId": entityId, + ], + ] + )) + } + + static func unlockLock( + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "service": "lock.unlock", + "service_data": [ + "entityId": entityId, + ], + ] + )) + } +} diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index 0c8800c1b..bf8e66a53 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -6,21 +6,48 @@ import UIKit public extension HAEntity { func onPress(for api: HomeAssistantAPI) -> Promise { - let domain = domain - var service: String - switch domain { - case "lock": - service = state == "unlocked" ? "lock" : "unlock" - case "cover": - service = state == "open" ? "close_cover" : "open_cover" - case "button", "input_button": - service = "press" - case "scene": - service = "turn_on" - default: - service = state == "on" ? "turn_off" : "turn_on" + switch Domain(rawValue: domain) { + case .button: + return api.connection.send(HATypedRequest.pressButton(entityId: entityId)).promise + .map { _ in () } + case .cover: + return api.connection.send(HATypedRequest.toggleDomain(domain: .cover, entityId: entityId)) + .promise.map { _ in () } + case .inputBoolean: + return api.connection + .send(HATypedRequest.toggleDomain(domain: .inputBoolean, entityId: entityId)).promise + .map { _ in () } + case .inputButton: + return api.connection + .send(HATypedRequest.toggleDomain(domain: .inputButton, entityId: entityId)).promise + .map { _ in () } + case .light: + return api.connection.send(HATypedRequest.toggleDomain(domain: .light, entityId: entityId)) + .promise.map { _ in () } + case .scene: + return api.connection.send(HATypedRequest.applyScene(entityId: entityId)).promise + .map { _ in () } + case .script: + return api.connection.send(HATypedRequest.runScript(entityId: entityId)).promise + .map { _ in () } + case .switch: + return api.connection.send(HATypedRequest.toggleDomain(domain: .switch, entityId: entityId)) + .promise.map { _ in () } + case .lock: + guard let state = Domain.State(rawValue: state) else { return .value } + switch state { + case .unlocking, .unlocked, .opening: + return api.connection.send(HATypedRequest.lockLock(entityId: entityId)).promise + .map { _ in () } + case .locked, .locking: + return api.connection.send(HATypedRequest.unlockLock(entityId: entityId)).promise + .map { _ in () } + default: + return .value + } + case .none: + return .value } - return api.CallService(domain: domain, service: service, serviceData: ["entity_id": entityId]) } func getIcon(size: CGSize = CGSize(width: 64, height: 64)) -> UIImage? { From b574c629f51ff7b7d6e6964057e12ced1a9cfa86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 19:10:22 +0100 Subject: [PATCH 32/41] Improve typed request --- .../CarPlay/HATypedRequest+CarPlay.swift | 7 +++- .../Common/Extensions/HAEntity+CarPlay.swift | 40 ++++++++----------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift index 3bbd43031..f0ddd3195 100644 --- a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift +++ b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift @@ -24,7 +24,8 @@ extension HATypedRequest { HATypedRequest(request: .init( type: "call_service", data: [ - "service": "script.\(entityId)", + "domain": "script", + "service": entityId.replacingOccurrences(of: "script.", with: ""), ] )) } @@ -35,6 +36,7 @@ extension HATypedRequest { HATypedRequest(request: .init( type: "call_service", data: [ + "domain": "scene", "service": "scene.apply", "service_data": [ "entities": [ @@ -51,6 +53,7 @@ extension HATypedRequest { HATypedRequest(request: .init( type: "call_service", data: [ + "domain": "button", "service": "button.press", "service_data": [ "entityId": entityId, @@ -65,6 +68,7 @@ extension HATypedRequest { HATypedRequest(request: .init( type: "call_service", data: [ + "domain": "lock", "service": "lock.lock", "service_data": [ "entityId": entityId, @@ -79,6 +83,7 @@ extension HATypedRequest { HATypedRequest(request: .init( type: "call_service", data: [ + "domain": "lock", "service": "lock.unlock", "service_data": [ "entityId": entityId, diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index bf8e66a53..7bf05df2d 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -6,46 +6,40 @@ import UIKit public extension HAEntity { func onPress(for api: HomeAssistantAPI) -> Promise { + var request: HATypedRequest? switch Domain(rawValue: domain) { case .button: - return api.connection.send(HATypedRequest.pressButton(entityId: entityId)).promise - .map { _ in () } + request = .pressButton(entityId: entityId) case .cover: - return api.connection.send(HATypedRequest.toggleDomain(domain: .cover, entityId: entityId)) - .promise.map { _ in () } + request = .toggleDomain(domain: .cover, entityId: entityId) case .inputBoolean: - return api.connection - .send(HATypedRequest.toggleDomain(domain: .inputBoolean, entityId: entityId)).promise - .map { _ in () } + request = .toggleDomain(domain: .inputBoolean, entityId: entityId) case .inputButton: - return api.connection - .send(HATypedRequest.toggleDomain(domain: .inputButton, entityId: entityId)).promise - .map { _ in () } + request = .toggleDomain(domain: .inputButton, entityId: entityId) case .light: - return api.connection.send(HATypedRequest.toggleDomain(domain: .light, entityId: entityId)) - .promise.map { _ in () } + request = .toggleDomain(domain: .light, entityId: entityId) case .scene: - return api.connection.send(HATypedRequest.applyScene(entityId: entityId)).promise - .map { _ in () } + request = .applyScene(entityId: entityId) case .script: - return api.connection.send(HATypedRequest.runScript(entityId: entityId)).promise - .map { _ in () } + request = .runScript(entityId: entityId) case .switch: - return api.connection.send(HATypedRequest.toggleDomain(domain: .switch, entityId: entityId)) - .promise.map { _ in () } + request = .toggleDomain(domain: .switch, entityId: entityId) case .lock: guard let state = Domain.State(rawValue: state) else { return .value } switch state { case .unlocking, .unlocked, .opening: - return api.connection.send(HATypedRequest.lockLock(entityId: entityId)).promise - .map { _ in () } + request = .lockLock(entityId: entityId) case .locked, .locking: - return api.connection.send(HATypedRequest.unlockLock(entityId: entityId)).promise - .map { _ in () } + request = .unlockLock(entityId: entityId) default: - return .value + break } case .none: + break + } + if let request { + return api.connection.send(request).promise.map { _ in () } + } else { return .value } } From 25b438a43c1a8657329482b7c5c4e7f1688bde5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Thu, 4 Jan 2024 19:38:33 +0100 Subject: [PATCH 33/41] Fix typed requests --- .../CarPlay/HATypedRequest+CarPlay.swift | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift index f0ddd3195..5f77eafeb 100644 --- a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift +++ b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift @@ -11,9 +11,9 @@ extension HATypedRequest { data: [ "domain": domain.rawValue, "service": "toggle", - "service_data": [ - "entity_id": entityId, - ], + "target": [ + "entity_id": entityId + ] ] )) } @@ -25,7 +25,7 @@ extension HATypedRequest { type: "call_service", data: [ "domain": "script", - "service": entityId.replacingOccurrences(of: "script.", with: ""), + "service": entityId.replacingOccurrences(of: "script.", with: "") ] )) } @@ -37,12 +37,10 @@ extension HATypedRequest { type: "call_service", data: [ "domain": "scene", - "service": "scene.apply", - "service_data": [ - "entities": [ - entityId, - ], - ], + "service": "turn_on", + "target": [ + "entity_id": entityId + ] ] )) } @@ -54,10 +52,10 @@ extension HATypedRequest { type: "call_service", data: [ "domain": "button", - "service": "button.press", - "service_data": [ - "entityId": entityId, - ], + "service": "press", + "target": [ + "entity_id": entityId + ] ] )) } @@ -69,10 +67,10 @@ extension HATypedRequest { type: "call_service", data: [ "domain": "lock", - "service": "lock.lock", - "service_data": [ - "entityId": entityId, - ], + "service": "lock", + "target": [ + "entity_id": entityId + ] ] )) } @@ -84,10 +82,10 @@ extension HATypedRequest { type: "call_service", data: [ "domain": "lock", - "service": "lock.unlock", - "service_data": [ - "entityId": entityId, - ], + "service": "unlock", + "target": [ + "entity_id": entityId + ] ] )) } From 766ea0d20a84a499a7f25b7c2ec2beb0910c7151 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 4 Jan 2024 19:08:16 +0000 Subject: [PATCH 34/41] Fix missing input_boolean domain. --- Sources/Shared/Domain/Domain.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Shared/Domain/Domain.swift b/Sources/Shared/Domain/Domain.swift index a8e5127ee..00eafc743 100644 --- a/Sources/Shared/Domain/Domain.swift +++ b/Sources/Shared/Domain/Domain.swift @@ -4,7 +4,7 @@ import UIKit public enum Domain: String, CaseIterable { case button case cover - case inputBoolean = "input_bollean" + case inputBoolean = "input_boolean" case inputButton = "input_button" case light case lock From f7237420fe9aa6e550eb503872254db665897803 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 4 Jan 2024 19:38:29 +0000 Subject: [PATCH 35/41] Fix update entities state. --- HomeAssistant.xcodeproj/project.pbxproj | 8 -------- Sources/Vehicle/Templates/DomainsListTemplate.swift | 4 +--- Sources/Vehicle/Templates/EntitiesListTemplate.swift | 9 +++++---- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index e4ebedc50..da09eacba 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -4065,18 +4065,10 @@ isa = PBXGroup; children = ( FD3BC66A29BA00B100B19FBE /* Templates */, - FD3BC66529BA001A00B19FBE /* Extensions */, ); path = Vehicle; sourceTree = ""; }; - FD3BC66529BA001A00B19FBE /* Extensions */ = { - isa = PBXGroup; - children = ( - ); - path = Extensions; - sourceTree = ""; - }; FD3BC66A29BA00B100B19FBE /* Templates */ = { isa = PBXGroup; children = ( diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 835151f35..43d935e18 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -78,9 +78,7 @@ class DomainsListTemplate { let entitiesGridTemplate = EntitiesListTemplate( domain: domain, server: server, - entitiesCachedStates: entitiesCachedStates.map({ cachedStates in - cachedStates.all.filter({ $0.domain == domain }) - }) + entitiesCachedStates: entitiesCachedStates ) interfaceController?.pushTemplate( diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift index d93249c8c..4a8a0fcea 100644 --- a/Sources/Vehicle/Templates/EntitiesListTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -10,7 +10,7 @@ class EntitiesListTemplate { private var stateSubscriptionToken: HACancellable? private let domain: String private var server: Server - private let entitiesCachedStates: HACache> + private let entitiesCachedStates: HACache private var listTemplate: CPListTemplate? private var currentPage: Int = 0 @@ -19,7 +19,7 @@ class EntitiesListTemplate { private var entitiesSubscriptionToken: HACancellable? - init(domain: String, server: Server, entitiesCachedStates: HACache>) { + init(domain: String, server: Server, entitiesCachedStates: HACache) { self.domain = domain self.server = server self.entitiesCachedStates = entitiesCachedStates @@ -43,8 +43,9 @@ class EntitiesListTemplate { private func updateListItems() { guard let entities = entitiesCachedStates.value else { return } - let entitiesSorted = entities - .sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) + + let entitiesFiltered = entities.all.filter { $0.domain == domain } + let entitiesSorted = entitiesFiltered.sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) let startIndex = currentPage * itemsPerPage let endIndex = min(startIndex + itemsPerPage, entitiesSorted.count) From b82b78112b4982587534198f59e5be033534c814 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 4 Jan 2024 19:53:00 +0000 Subject: [PATCH 36/41] Fix input button service. --- Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift | 3 ++- Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift index 5f77eafeb..dc150f254 100644 --- a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift +++ b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift @@ -46,12 +46,13 @@ extension HATypedRequest { } static func pressButton( + domain: Domain, entityId: String ) -> HATypedRequest { HATypedRequest(request: .init( type: "call_service", data: [ - "domain": "button", + "domain": domain.rawValue, "service": "press", "target": [ "entity_id": entityId diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index 7bf05df2d..c24595861 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -9,13 +9,13 @@ public extension HAEntity { var request: HATypedRequest? switch Domain(rawValue: domain) { case .button: - request = .pressButton(entityId: entityId) + request = .pressButton(domain: .button, entityId: entityId) case .cover: request = .toggleDomain(domain: .cover, entityId: entityId) case .inputBoolean: request = .toggleDomain(domain: .inputBoolean, entityId: entityId) case .inputButton: - request = .toggleDomain(domain: .inputButton, entityId: entityId) + request = .pressButton(domain: .inputButton, entityId: entityId) case .light: request = .toggleDomain(domain: .light, entityId: entityId) case .scene: From d4aa317b3c10ed398bac6c09975a00af65148c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Fri, 5 Jan 2024 00:45:05 +0100 Subject: [PATCH 37/41] Add lock operations alert confirmation --- .../Resources/en.lproj/Localizable.strings | 3 + .../Common/Extensions/HAEntity+CarPlay.swift | 9 ++- .../Shared/Resources/Swiftgen/Strings.swift | 18 +++++ .../Templates/DomainsListTemplate.swift | 6 +- .../Templates/EntitiesListTemplate.swift | 79 ++++++++++++++----- 5 files changed, 90 insertions(+), 25 deletions(-) diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 84955b027..556dfb853 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -33,6 +33,7 @@ "alerts.auth_required.message" = "The server has rejected your credentials, and you must sign in again to continue."; "alerts.auth_required.title" = "You must sign in to continue"; "alerts.confirm.cancel" = "Cancel"; +"alerts.confirm.confirm" = "Confirm"; "alerts.confirm.ok" = "OK"; "alerts.deprecations.notification_category.message" = "You must migrate to actions defined in the notification itself before %1$@."; "alerts.deprecations.notification_category.title" = "Notification Categories are deprecated"; @@ -792,3 +793,5 @@ Home Assistant is free and open source home automation software with a focus on "carplay.labels.empty_domain_list" = "No domains available"; "carplay.labels.no_servers_available" = "No servers available. Add a server in the app."; "carplay.labels.already_added_server" = "Already added"; +"carplay.lock.confirmation.title" = "Are you sure you want to perform lock action on %@?"; +"carplay.unlock.confirmation.title" = "Are you sure you want to perform unlock action on %@?"; diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index c24595861..be62aac06 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -113,9 +113,12 @@ public extension HAEntity { } } - // TODO: Improve logic and create enum for states - if state == "on" { - tint = .yellow + if let state = Domain.State(rawValue: state) { + if [.on, .open, .opening, .unlocked, .unlocking].contains(state) { + tint = Constants.tintColor + } else if [.unavailable, .unknown].contains(state) { + tint = .gray + } } return image.image(ofSize: size, color: tint) diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 2a3332fbf..8fe8194c8 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -183,6 +183,8 @@ public enum L10n { public enum Confirm { /// Cancel public static var cancel: String { return L10n.tr("Localizable", "alerts.confirm.cancel") } + /// Confirm + public static var confirm: String { return L10n.tr("Localizable", "alerts.confirm.confirm") } /// OK public static var ok: String { return L10n.tr("Localizable", "alerts.confirm.ok") } } @@ -242,6 +244,14 @@ public enum L10n { /// Servers public static var servers: String { return L10n.tr("Localizable", "carplay.labels.servers") } } + public enum Lock { + public enum Confirmation { + /// Are you sure you want to perform lock action on %@? + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "carplay.lock.confirmation.title", String(describing: p1)) + } + } + } public enum Navigation { public enum Button { /// Next @@ -250,6 +260,14 @@ public enum L10n { public static var previous: String { return L10n.tr("Localizable", "carplay.navigation.button.previous") } } } + public enum Unlock { + public enum Confirmation { + /// Are you sure you want to perform unlock action on %@? + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "carplay.unlock.confirmation.title", String(describing: p1)) + } + } + } } public enum ClError { diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 43d935e18..7a86da103 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -75,16 +75,18 @@ class DomainsListTemplate { } private func listItemHandler(domain: String) { - let entitiesGridTemplate = EntitiesListTemplate( + let entitiesListTemplate = EntitiesListTemplate( + title: Domain(rawValue: domain)?.localizedDescription ?? domain, domain: domain, server: server, entitiesCachedStates: entitiesCachedStates ) interfaceController?.pushTemplate( - entitiesGridTemplate.getTemplate(), + entitiesListTemplate.getTemplate(), animated: true, completion: nil ) + entitiesListTemplate.interfaceController = interfaceController } } diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift index 4a8a0fcea..6e10ed7f5 100644 --- a/Sources/Vehicle/Templates/EntitiesListTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -5,7 +5,18 @@ import PromiseKit import Shared @available(iOS 16.0, *) -class EntitiesListTemplate { +final class EntitiesListTemplate { + + enum GridPage { + case Next + case Previous + } + + enum CPEntityError: Error { + case unknown + } + + private let title: String private let entityIconSize: CGSize = .init(width: 64, height: 64) private var stateSubscriptionToken: HACancellable? private let domain: String @@ -14,15 +25,16 @@ class EntitiesListTemplate { private var listTemplate: CPListTemplate? private var currentPage: Int = 0 - /// Maximum number of items per page minus pagination buttons private var itemsPerPage: Int = CPListTemplate.maximumItemCount - private var entitiesSubscriptionToken: HACancellable? - init(domain: String, server: Server, entitiesCachedStates: HACache) { + weak var interfaceController: CPInterfaceController? + + init(title: String, domain: String, server: Server, entitiesCachedStates: HACache) { self.domain = domain self.server = server self.entitiesCachedStates = entitiesCachedStates + self.title = title } public func getTemplate() -> CPListTemplate { @@ -36,14 +48,14 @@ class EntitiesListTemplate { if let listTemplate = listTemplate { return listTemplate } else { - listTemplate = CPListTemplate(title: "", sections: []) + listTemplate = CPListTemplate(title: title, sections: []) return listTemplate! } } private func updateListItems() { guard let entities = entitiesCachedStates.value else { return } - + let entitiesFiltered = entities.all.filter { $0.domain == domain } let entitiesSorted = entitiesFiltered.sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) @@ -62,12 +74,24 @@ class EntitiesListTemplate { item.handler = { _, completion in firstly { [weak self] () -> Promise in guard let self = self else { return .init(error: CPEntityError.unknown) } + let api = Current.api(for: self.server) - return entity.onPress(for: api) + + if let domain = Domain(rawValue: entity.domain), domain == .lock { + self.displayLockConfirmation(entity: entity, completion: { + entity.onPress(for: api).catch { error in + Current.Log.error("Received error from callService during onPress call: \(error)") + } + }) + return .value + } else { + return entity.onPress(for: api) + } }.done { completion() }.catch { error in Current.Log.error("Received error from callService during onPress call: \(error)") + completion() } } @@ -86,10 +110,35 @@ class EntitiesListTemplate { listTemplate?.updateSections([CPListSection(items: items)]) } - func getPageButtons(endIndex: Int, currentPage: Int, totalCount: Int) -> [CPBarButton] { + private func displayLockConfirmation(entity: HAEntity, completion: @escaping () -> Void) { + guard let state = Domain.State(rawValue: entity.state) else { return } + var title = "" + switch state { + case .locked, .locking: + title = L10n.Carplay.Unlock.Confirmation.title(entity.attributes.friendlyName ?? entity.entityId) + default: + title = L10n.Carplay.Lock.Confirmation.title(entity.attributes.friendlyName ?? entity.entityId) + } + + let alert = CPAlertTemplate(titleVariants: [title], actions: [ + .init(title: L10n.Alerts.Confirm.cancel, style: .cancel, handler: { [weak self] _ in + self?.interfaceController?.dismissTemplate(animated: true, completion: nil) + }), + .init(title: L10n.Alerts.Confirm.confirm, style: .destructive, handler: { [weak self] _ in + completion() + self?.interfaceController?.dismissTemplate(animated: true, completion: nil) + }) + ]) + + interfaceController?.presentTemplate(alert, animated: true, completion: nil) + } + + private func getPageButtons(endIndex: Int, currentPage: Int, totalCount: Int) -> [CPBarButton] { var barButtons: [CPBarButton] = [] - let forwardImage = UIImage(systemName: "arrow.forward")! + guard let forwardImage = UIImage(systemName: "arrow.forward"), + let backwardImage = UIImage(systemName: "arrow.backward") else { return [] } + if endIndex < totalCount { barButtons.append(CPBarButton( image: forwardImage, @@ -105,7 +154,6 @@ class EntitiesListTemplate { )) } - let backwardImage = UIImage(systemName: "arrow.backward")! if currentPage > 0 { barButtons.append(CPBarButton( image: backwardImage, @@ -124,7 +172,7 @@ class EntitiesListTemplate { return barButtons } - func changePage(to: GridPage) { + private func changePage(to: GridPage) { switch to { case .Next: currentPage += 1 @@ -134,12 +182,3 @@ class EntitiesListTemplate { updateListItems() } } - -enum GridPage { - case Next - case Previous -} - -enum CPEntityError: Error { - case unknown -} From b17cc21e95b2deabc73bbe24308c42f6f127bba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Fri, 5 Jan 2024 00:47:19 +0100 Subject: [PATCH 38/41] Lint --- .../CarPlay/HATypedRequest+CarPlay.swift | 22 +++++++++---------- .../Common/Extensions/HAEntity+CarPlay.swift | 2 +- .../Templates/EntitiesListTemplate.swift | 6 ++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift index dc150f254..8e81a72e9 100644 --- a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift +++ b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift @@ -12,8 +12,8 @@ extension HATypedRequest { "domain": domain.rawValue, "service": "toggle", "target": [ - "entity_id": entityId - ] + "entity_id": entityId, + ], ] )) } @@ -25,7 +25,7 @@ extension HATypedRequest { type: "call_service", data: [ "domain": "script", - "service": entityId.replacingOccurrences(of: "script.", with: "") + "service": entityId.replacingOccurrences(of: "script.", with: ""), ] )) } @@ -39,8 +39,8 @@ extension HATypedRequest { "domain": "scene", "service": "turn_on", "target": [ - "entity_id": entityId - ] + "entity_id": entityId, + ], ] )) } @@ -55,8 +55,8 @@ extension HATypedRequest { "domain": domain.rawValue, "service": "press", "target": [ - "entity_id": entityId - ] + "entity_id": entityId, + ], ] )) } @@ -70,8 +70,8 @@ extension HATypedRequest { "domain": "lock", "service": "lock", "target": [ - "entity_id": entityId - ] + "entity_id": entityId, + ], ] )) } @@ -85,8 +85,8 @@ extension HATypedRequest { "domain": "lock", "service": "unlock", "target": [ - "entity_id": entityId - ] + "entity_id": entityId, + ], ] )) } diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index be62aac06..f8fea9441 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -35,7 +35,7 @@ public extension HAEntity { break } case .none: - break + break } if let request { return api.connection.send(request).promise.map { _ in () } diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift index 6e10ed7f5..fa3bc2f45 100644 --- a/Sources/Vehicle/Templates/EntitiesListTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -6,7 +6,6 @@ import Shared @available(iOS 16.0, *) final class EntitiesListTemplate { - enum GridPage { case Next case Previous @@ -57,7 +56,8 @@ final class EntitiesListTemplate { guard let entities = entitiesCachedStates.value else { return } let entitiesFiltered = entities.all.filter { $0.domain == domain } - let entitiesSorted = entitiesFiltered.sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) + let entitiesSorted = entitiesFiltered + .sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) let startIndex = currentPage * itemsPerPage let endIndex = min(startIndex + itemsPerPage, entitiesSorted.count) @@ -127,7 +127,7 @@ final class EntitiesListTemplate { .init(title: L10n.Alerts.Confirm.confirm, style: .destructive, handler: { [weak self] _ in completion() self?.interfaceController?.dismissTemplate(animated: true, completion: nil) - }) + }), ]) interfaceController?.presentTemplate(alert, animated: true, completion: nil) From ce388d094bd419f42fda567bed9874c8fac070eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Fri, 5 Jan 2024 01:11:58 +0100 Subject: [PATCH 39/41] Reduce cyclomatic complexity --- .../Common/Extensions/HAEntity+CarPlay.swift | 103 ++++++++++-------- 1 file changed, 60 insertions(+), 43 deletions(-) diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift index f8fea9441..05b9a286b 100644 --- a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -51,65 +51,26 @@ public extension HAEntity { if let icon = attributes.icon?.normalizingIconString { image = MaterialDesignIcons(named: icon) } else { - guard let compareState = Domain.State(rawValue: state) else { return nil } guard let domain = Domain(rawValue: domain) else { return nil } switch domain { case .button: - guard let deviceClass = attributes.dictionary["device_class"] as? String else { break } - if deviceClass == "restart" { - image = MaterialDesignIcons.restartIcon - } else if deviceClass == "update" { - image = MaterialDesignIcons.packageUpIcon - } else { - image = MaterialDesignIcons.gestureTapButtonIcon - } + image = getButtonIcon() case .cover: image = getCoverIcon() case .inputBoolean: - if !entityId.hasSuffix(".ha_ios_placeholder") { - if compareState == .on { - image = MaterialDesignIcons.checkCircleOutlineIcon - } else { - image = MaterialDesignIcons.closeCircleOutlineIcon - } - } else { - image = MaterialDesignIcons.toggleSwitchOutlineIcon - } + image = getInputBooleanIcon() case .inputButton: image = MaterialDesignIcons.gestureTapButtonIcon case .light: image = MaterialDesignIcons.lightbulbIcon case .lock: - switch compareState { - case .unlocked: - image = MaterialDesignIcons.lockOpenIcon - case .jammed: - image = MaterialDesignIcons.lockAlertIcon - case .locking, .unlocking: - image = MaterialDesignIcons.lockClockIcon - default: - image = MaterialDesignIcons.lockIcon - } + image = getLockIcon() case .scene: image = MaterialDesignIcons.paletteOutlineIcon case .script: image = MaterialDesignIcons.scriptTextOutlineIcon case .switch: - if !entityId.hasSuffix(".ha_ios_placeholder") { - let deviceClass = attributes.dictionary["device_class"] as? String - switch deviceClass { - case "outlet": - image = compareState == .on ? MaterialDesignIcons.powerPlugIcon : MaterialDesignIcons - .powerPlugOffIcon - case "switch": - image = compareState == .on ? MaterialDesignIcons.toggleSwitchIcon : MaterialDesignIcons - .toggleSwitchOffIcon - default: - image = MaterialDesignIcons.flashIcon - } - } else { - image = MaterialDesignIcons.lightSwitchIcon - } + image = getSwitchIcon() } } @@ -124,6 +85,62 @@ public extension HAEntity { return image.image(ofSize: size, color: tint) } + private func getInputBooleanIcon() -> MaterialDesignIcons { + if !entityId.hasSuffix(".ha_ios_placeholder"), let compareState = Domain.State(rawValue: state) { + if compareState == .on { + return MaterialDesignIcons.checkCircleOutlineIcon + } else { + return MaterialDesignIcons.closeCircleOutlineIcon + } + } else { + return MaterialDesignIcons.toggleSwitchOutlineIcon + } + } + + private func getButtonIcon() -> MaterialDesignIcons { + guard let deviceClass = attributes.dictionary["device_class"] as? String else { return MaterialDesignIcons.gestureTapButtonIcon } + if deviceClass == "restart" { + return MaterialDesignIcons.restartIcon + } else if deviceClass == "update" { + return MaterialDesignIcons.packageUpIcon + } else { + return MaterialDesignIcons.gestureTapButtonIcon + } + } + + private func getLockIcon() -> MaterialDesignIcons { + guard let compareState = Domain.State(rawValue: state) else { return MaterialDesignIcons.lockIcon } + switch compareState { + case .unlocked: + return MaterialDesignIcons.lockOpenIcon + case .jammed: + return MaterialDesignIcons.lockAlertIcon + case .locking, .unlocking: + return MaterialDesignIcons.lockClockIcon + default: + return MaterialDesignIcons.lockIcon + } + } + + private func getSwitchIcon() -> MaterialDesignIcons { + guard let compareState = Domain.State(rawValue: state) else { return MaterialDesignIcons.lightSwitchIcon } + if !entityId.hasSuffix(".ha_ios_placeholder") { + let deviceClass = attributes.dictionary["device_class"] as? String + switch deviceClass { + case "outlet": + return compareState == .on ? MaterialDesignIcons.powerPlugIcon : MaterialDesignIcons + .powerPlugOffIcon + case "switch": + return compareState == .on ? MaterialDesignIcons.toggleSwitchIcon : MaterialDesignIcons + .toggleSwitchOffIcon + default: + return MaterialDesignIcons.flashIcon + } + } else { + return MaterialDesignIcons.lightSwitchIcon + } + } + private func getCoverIcon() -> MaterialDesignIcons { let device_class = attributes.dictionary["device_class"] as? String let state = state From 3c82922e79114fd99b4095c670e58efe51921eac Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Fri, 5 Jan 2024 18:51:03 +0000 Subject: [PATCH 40/41] Cancel the entity subscription token when the entities list template will disappear. --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 17 +++++++-- .../Templates/DomainsListTemplate.swift | 35 +++++++++++-------- .../Templates/EntitiesListTemplate.swift | 29 ++++++++------- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index 79323ca10..d4a51326b 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -13,13 +13,11 @@ public protocol EntitiesStateSubscription { class CarPlaySceneDelegate: UIResponder { private var interfaceController: CPInterfaceController? private var entities: HACache>? - private var entitiesGridTemplate: EntitiesListTemplate? private var domainsListTemplate: DomainsListTemplate? + private var serverId: Identifier? private let carPlayPreferredServerKey = "carPlay-server" - private var serverId: Identifier? - private func setServer(server: Server) { serverId = server.identifier prefs.set(server.identifier.rawValue, forKey: carPlayPreferredServerKey) @@ -134,6 +132,7 @@ extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { didConnect interfaceController: CPInterfaceController ) { self.interfaceController = interfaceController + self.interfaceController?.delegate = self if let serverIdentifier = prefs.string(forKey: carPlayPreferredServerKey), let selectedServer = Current.servers.server(forServerIdentifier: serverIdentifier) { @@ -203,3 +202,15 @@ extension CarPlaySceneDelegate: ServerObserver { setServer(server: server) } } + +@available(iOS 16.0, *) +extension CarPlaySceneDelegate: CPInterfaceControllerDelegate { + func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) { + domainsListTemplate?.templateWillDisappear(template: aTemplate) + } +} + +protocol CarPlayTemplateProvider { + var template: CPTemplate { get set } + func templateWillDisappear(template: CPTemplate) +} diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 7a86da103..8a2ab0e8f 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -4,25 +4,18 @@ import HAKit import Shared @available(iOS 16.0, *) -class DomainsListTemplate { +class DomainsListTemplate: CarPlayTemplateProvider { private let title: String private let entitiesCachedStates: HACache private let serverButtonHandler: CPBarButtonHandler? private let server: Server private var domainList: [String] = [] - private var listTemplate: CPListTemplate? + private var childTemplateProvider: CarPlayTemplateProvider? weak var interfaceController: CPInterfaceController? - var template: CPListTemplate { - guard let listTemplate = listTemplate else { - listTemplate = CPListTemplate(title: title, sections: []) - listTemplate?.emptyViewSubtitleVariants = [L10n.Carplay.Labels.emptyDomainList] - return listTemplate! - } - return listTemplate - } + var template: CPTemplate init( title: String, @@ -34,15 +27,23 @@ class DomainsListTemplate { self.entitiesCachedStates = entities self.serverButtonHandler = serverButtonHandler self.server = server + + let listTemplate = CPListTemplate(title: title, sections: []) + listTemplate.emptyViewSubtitleVariants = [L10n.Carplay.Labels.emptyDomainList] + self.template = listTemplate } func setServerListButton(show: Bool) { + guard let listTemplate = template as? CPListTemplate else { + return + } + if show { - listTemplate? + listTemplate .trailingNavigationBarButtons = [CPBarButton(title: L10n.Carplay.Labels.servers, handler: serverButtonHandler)] } else { - listTemplate?.trailingNavigationBarButtons.removeAll() + listTemplate.trailingNavigationBarButtons.removeAll() } } @@ -71,7 +72,11 @@ class DomainsListTemplate { } domainList = domains - listTemplate?.updateSections([CPListSection(items: items)]) + (template as? CPListTemplate)?.updateSections([CPListSection(items: items)]) + } + + func templateWillDisappear(template: CPTemplate) { + childTemplateProvider?.templateWillDisappear(template: template) } private func listItemHandler(domain: String) { @@ -82,11 +87,13 @@ class DomainsListTemplate { entitiesCachedStates: entitiesCachedStates ) + entitiesListTemplate.interfaceController = interfaceController + + childTemplateProvider = entitiesListTemplate interfaceController?.pushTemplate( entitiesListTemplate.getTemplate(), animated: true, completion: nil ) - entitiesListTemplate.interfaceController = interfaceController } } diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift index fa3bc2f45..261461ddc 100644 --- a/Sources/Vehicle/Templates/EntitiesListTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -5,7 +5,7 @@ import PromiseKit import Shared @available(iOS 16.0, *) -final class EntitiesListTemplate { +final class EntitiesListTemplate: CarPlayTemplateProvider { enum GridPage { case Next case Previous @@ -15,28 +15,26 @@ final class EntitiesListTemplate { case unknown } - private let title: String private let entityIconSize: CGSize = .init(width: 64, height: 64) - private var stateSubscriptionToken: HACancellable? private let domain: String private var server: Server private let entitiesCachedStates: HACache - private var listTemplate: CPListTemplate? private var currentPage: Int = 0 private var itemsPerPage: Int = CPListTemplate.maximumItemCount private var entitiesSubscriptionToken: HACancellable? + var template: CPTemplate weak var interfaceController: CPInterfaceController? init(title: String, domain: String, server: Server, entitiesCachedStates: HACache) { self.domain = domain self.server = server self.entitiesCachedStates = entitiesCachedStates - self.title = title + self.template = CPListTemplate(title: title, sections: []) } - public func getTemplate() -> CPListTemplate { + public func getTemplate() -> CPTemplate { defer { updateListItems() entitiesSubscriptionToken = entitiesCachedStates.subscribe { [weak self] _, _ in @@ -44,16 +42,11 @@ final class EntitiesListTemplate { } } - if let listTemplate = listTemplate { - return listTemplate - } else { - listTemplate = CPListTemplate(title: title, sections: []) - return listTemplate! - } + return template } private func updateListItems() { - guard let entities = entitiesCachedStates.value else { return } + guard let entities = entitiesCachedStates.value, let listTemplate = template as? CPListTemplate else { return } let entitiesFiltered = entities.all.filter { $0.domain == domain } let entitiesSorted = entitiesFiltered @@ -100,14 +93,14 @@ final class EntitiesListTemplate { // Add pagination buttons if needed if entitiesSorted.count > itemsPerPage { - listTemplate?.trailingNavigationBarButtons = getPageButtons( + listTemplate.trailingNavigationBarButtons = getPageButtons( endIndex: endIndex, currentPage: currentPage, totalCount: entitiesSorted.count ) } - listTemplate?.updateSections([CPListSection(items: items)]) + listTemplate.updateSections([CPListSection(items: items)]) } private func displayLockConfirmation(entity: HAEntity, completion: @escaping () -> Void) { @@ -181,4 +174,10 @@ final class EntitiesListTemplate { } updateListItems() } + + func templateWillDisappear(template: CPTemplate) { + if self.template == template { + entitiesSubscriptionToken?.cancel() + } + } } From e6dc0602f6172ec473c1e30d6a150eec6df55761 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Fri, 5 Jan 2024 19:47:43 +0000 Subject: [PATCH 41/41] Update the domain list if the entitiesCachedStates changes. --- Sources/App/Scenes/CarPlaySceneDelegate.swift | 5 +++++ .../Vehicle/Templates/DomainsListTemplate.swift | 16 +++++++++++++++- .../Vehicle/Templates/EntitiesListTemplate.swift | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift index d4a51326b..646307244 100644 --- a/Sources/App/Scenes/CarPlaySceneDelegate.swift +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -208,9 +208,14 @@ extension CarPlaySceneDelegate: CPInterfaceControllerDelegate { func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) { domainsListTemplate?.templateWillDisappear(template: aTemplate) } + + func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) { + domainsListTemplate?.templateWillAppear(template: aTemplate) + } } protocol CarPlayTemplateProvider { var template: CPTemplate { get set } func templateWillDisappear(template: CPTemplate) + func templateWillAppear(template: CPTemplate) } diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift index 8a2ab0e8f..88aec044c 100644 --- a/Sources/Vehicle/Templates/DomainsListTemplate.swift +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -76,9 +76,23 @@ class DomainsListTemplate: CarPlayTemplateProvider { } func templateWillDisappear(template: CPTemplate) { - childTemplateProvider?.templateWillDisappear(template: template) + if self.template == template { + entitiesSubscriptionToken?.cancel() + } else { + childTemplateProvider?.templateWillDisappear(template: template) + } } + func templateWillAppear(template: CPTemplate) { + if self.template == template { + entitiesSubscriptionToken = entitiesCachedStates.subscribe { [weak self] _, _ in + self?.updateSections() + } + } + } + + var entitiesSubscriptionToken: HACancellable? + private func listItemHandler(domain: String) { let entitiesListTemplate = EntitiesListTemplate( title: Domain(rawValue: domain)?.localizedDescription ?? domain, diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift index 261461ddc..b6674c304 100644 --- a/Sources/Vehicle/Templates/EntitiesListTemplate.swift +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -180,4 +180,6 @@ final class EntitiesListTemplate: CarPlayTemplateProvider { entitiesSubscriptionToken?.cancel() } } + + func templateWillAppear(template: CPTemplate) {} }