Skip to content

Commit f57b549

Browse files
bgoncalCopilot
andauthored
Improve custom widget state handling and URL management (#4262)
Refactored custom widget to use AppConstants.createCustomWidgetURL for deeplink construction. Enhanced domain state handling by introducing isActive property and ensuring state comparison is case-insensitive. Updated icon color logic to support additional domains (cover, fan) and improved clarity in state checks. <!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> <img width="1342" height="952" alt="CleanShot 2026-01-28 at 10 44 39@2x" src="https://github.com/user-attachments/assets/0d92a047-2356-45b5-8208-423d0b669e4b" /> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. --> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent efe9855 commit f57b549

File tree

8 files changed

+161
-64
lines changed

8 files changed

+161
-64
lines changed

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,6 @@
673673
423179812D54FADD0037A8A4 /* AppIntentHaptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4231797F2D54FADD0037A8A4 /* AppIntentHaptics.swift */; };
674674
42333ADB2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */; };
675675
42333ADC2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */; };
676-
423622A02F05587800391BD0 /* EntityIconColorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */; };
677676
4237E6392E5333370023B673 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 4237E6382E5333370023B673 /* ZIPFoundation */; };
678677
42383F702D9576F700C745F2 /* AppTriggerSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42383F6F2D9576F700C745F2 /* AppTriggerSource.swift */; };
679678
42383F712D9576F700C745F2 /* AppTriggerSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42383F6F2D9576F700C745F2 /* AppTriggerSource.swift */; };
@@ -847,6 +846,8 @@
847846
428626062DA5CCAE00D58D13 /* ExternalLinkButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428626032DA5CCAE00D58D13 /* ExternalLinkButtonTests.swift */; };
848847
428626072DA5CCAE00D58D13 /* HAButtonStyles.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428626042DA5CCAE00D58D13 /* HAButtonStyles.test.swift */; };
849848
4286260E2DA5CD1B00D58D13 /* WebhookSensorIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4286260B2DA5CD1B00D58D13 /* WebhookSensorIdTests.swift */; };
849+
4286A2EF2F2A0F9200003459 /* EntityIconColorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */; };
850+
4286A2F02F2A0F9200003459 /* EntityIconColorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */; };
850851
42881BD42DDF12340079BDCB /* SwiftUI+SafeArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42881BD32DDF12340079BDCB /* SwiftUI+SafeArea.swift */; };
851852
428830EB2C6E3A8D0012373D /* WatchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428830EA2C6E3A8D0012373D /* WatchHomeView.swift */; };
852853
428830ED2C6E3A9A0012373D /* WatchHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */; };
@@ -882,7 +883,6 @@
882883
428DC00D2F0D8BD5003B08D5 /* FanControlsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428DC00C2F0D8BD5003B08D5 /* FanControlsViewModel.swift */; };
883884
428DC00F2F0D8BF5003B08D5 /* FanControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428DC00E2F0D8BF5003B08D5 /* FanControlsView.swift */; };
884885
428DC0112F0D8C96003B08D5 /* FanControlIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428DC0102F0D8C96003B08D5 /* FanControlIntents.swift */; };
885-
428DC0142F0D921F003B08D5 /* Domain+AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428DC0132F0D921F003B08D5 /* Domain+AccentColor.swift */; };
886886
428DC0162F0DE882003B08D5 /* EntityConfigurationWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428DC0152F0DE882003B08D5 /* EntityConfigurationWebView.swift */; };
887887
428ED98B2E9E4FBF0019113B /* CheckmarkDrawOnView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428ED98A2E9E4FBF0019113B /* CheckmarkDrawOnView.swift */; };
888888
428ED98E2E9E555D0019113B /* OnboardingPermissionsNavigationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428ED98C2E9E54E60019113B /* OnboardingPermissionsNavigationViewModelTests.swift */; };
@@ -2547,7 +2547,6 @@
25472547
428DC00C2F0D8BD5003B08D5 /* FanControlsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControlsViewModel.swift; sourceTree = "<group>"; };
25482548
428DC00E2F0D8BF5003B08D5 /* FanControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControlsView.swift; sourceTree = "<group>"; };
25492549
428DC0102F0D8C96003B08D5 /* FanControlIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControlIntents.swift; sourceTree = "<group>"; };
2550-
428DC0132F0D921F003B08D5 /* Domain+AccentColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Domain+AccentColor.swift"; sourceTree = "<group>"; };
25512550
428DC0152F0DE882003B08D5 /* EntityConfigurationWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityConfigurationWebView.swift; sourceTree = "<group>"; };
25522551
428ED98A2E9E4FBF0019113B /* CheckmarkDrawOnView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckmarkDrawOnView.swift; sourceTree = "<group>"; };
25532552
428ED98C2E9E54E60019113B /* OnboardingPermissionsNavigationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPermissionsNavigationViewModelTests.swift; sourceTree = "<group>"; };
@@ -5824,7 +5823,6 @@
58245823
children = (
58255824
42DFE8772EFB08C80058DADB /* HomeView.swift */,
58265825
42DFE8782EFB08C80058DADB /* HomeViewModel.swift */,
5827-
428DC0132F0D921F003B08D5 /* Domain+AccentColor.swift */,
58285826
42F5C0822F02B6DB00C50310 /* HomeSectionsReorderView.swift */,
58295827
4289FCB82F0BFF50005189AA /* HomeViewConfiguration.swift */,
58305828
420C914F2F0C6CAC005D04A6 /* HomeViewCustomizationView.swift */,
@@ -5838,7 +5836,6 @@
58385836
isa = PBXGroup;
58395837
children = (
58405838
42DFE87C2EFB128B0058DADB /* EntityTileView.swift */,
5841-
4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */,
58425839
42C60F902F07FB0F0071A6F6 /* MoreInfoDialog */,
58435840
426D2F022F17E8530062C359 /* HomeEntityTileView.swift */,
58445841
);
@@ -7076,6 +7073,7 @@
70767073
isa = PBXGroup;
70777074
children = (
70787075
42BB4C362CD26490003E47FD /* HATypedRequest+App.swift */,
7076+
4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */,
70797077
429C33C02F17A6CF0033EF5E /* Models */,
70807078
4245DC022EBA42C3005E0E04 /* AreasService.swift */,
70817079
420CFC612D3F9C15009A94F3 /* Database */,
@@ -9433,7 +9431,6 @@
94339431
42C60FA22F0811AE0071A6F6 /* SwitchControlIntents.swift in Sources */,
94349432
42B19BD12D4A358B00B3262B /* DebugView.swift in Sources */,
94359433
B63CAE6B2150D2E300A68AFB /* VoiceShortcutsManager.swift in Sources */,
9436-
428DC0142F0D921F003B08D5 /* Domain+AccentColor.swift in Sources */,
94379434
1164DA3225FBF5D600515E8A /* UITextView+CodeRow.swift in Sources */,
94389435
4245DC012EBA3DD3005E0E04 /* EntityPickerViewModel.swift in Sources */,
94399436
42FCCFE12B9B1B610057783F /* BarcodeScannerDataModel.swift in Sources */,
@@ -9479,7 +9476,6 @@
94799476
42B000122EC5D6CD005FC67F /* OpenSettingsDestination.swift in Sources */,
94809477
425573CE2B5574F100145217 /* CarPlayAreasViewModel.swift in Sources */,
94819478
42BA1BC92C8864C200A2FC36 /* OpenPageAppIntent.swift in Sources */,
9482-
423622A02F05587800391BD0 /* EntityIconColorProvider.swift in Sources */,
94839479
427FEE0E2D9C22310047C00C /* ManualURLEntryView.swift in Sources */,
94849480
428D31A52D0B33AF0025B1D7 /* WidgetSensorsConfig.swift in Sources */,
94859481
119A827C252A3C4700D7000D /* NFCNDEFPayload+Additions.swift in Sources */,
@@ -9701,6 +9697,7 @@
97019697
11521BBD25400284009C5C72 /* CrashReporter.swift in Sources */,
97029698
42462E6E2DA4094300ECC8A7 /* WebhookSensorId.swift in Sources */,
97039699
424151FD2CD8F27100D7A6F9 /* CarPlayConfig.swift in Sources */,
9700+
4286A2F02F2A0F9200003459 /* EntityIconColorProvider.swift in Sources */,
97049701
42DFE8802EFB23240058DADB /* HAEntity+CarPlay.swift in Sources */,
97059702
113A8D4A283C7B1700B9DA32 /* PeriodicUpdateManager.swift in Sources */,
97069703
42DC8B7A2E169FA300D9999E /* Color+Hex.swift in Sources */,
@@ -9986,6 +9983,7 @@
99869983
11EE9B4924C5116F00404AF8 /* LegacyModelManager.swift in Sources */,
99879984
46C62BA12D8A3799002C0001 /* SnapshottablePreviewConfigurations.swift in Sources */,
99889985
42CE8FB62B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */,
9986+
4286A2EF2F2A0F9200003459 /* EntityIconColorProvider.swift in Sources */,
99899987
D0C3DC142134CD4E000C9EE1 /* CMMotion+StringExtensions.swift in Sources */,
99909988
B6872E662226842100C475D1 /* MobileAppRegistrationResponse.swift in Sources */,
99919989
D0EEF305214DD0D400D1D360 /* UIColor+HA.swift in Sources */,

Sources/App/WebView/ExperimentalSpace/Home/Domain+AccentColor.swift

Lines changed: 0 additions & 20 deletions
This file was deleted.

Sources/Extensions/Widgets/Custom/WidgetCustom.swift

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ struct WidgetCustom: Widget {
3333
}
3434

3535
private var emptyView: some View {
36-
let url = URL(string: "\(AppConstants.deeplinkURL.absoluteString)createCustomWidget")!
37-
return Link(destination: url.withWidgetAuthenticity()) {
36+
Link(destination: AppConstants.createCustomWidgetURL.withWidgetAuthenticity()) {
3837
VStack(spacing: DesignSystem.Spaces.two) {
3938
Image(systemSymbol: .squareBadgePlusFill)
4039
.foregroundStyle(Color.haPrimary)
@@ -88,11 +87,11 @@ struct WidgetCustom: Widget {
8887

8988
if !widget.itemsStates.isEmpty {
9089
return Color.gray
91-
} else if showStates, [.light, .switch, .inputBoolean].contains(magicItem.domain) {
92-
if state?.domainState == Domain.State.off {
93-
return Color.gray
90+
} else if showStates, [.light, .switch, .inputBoolean, .cover, .fan].contains(magicItem.domain) {
91+
if state?.domainState?.isActive ?? false {
92+
return state?.color ?? magicItemIconColor
9493
} else {
95-
return magicItemIconColor
94+
return Color.gray
9695
}
9796
} else {
9897
return magicItemIconColor

Sources/Extensions/Widgets/Custom/WidgetCustomTimelineProvider.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import AppIntents
22
import GRDB
33
import Shared
4+
import SwiftUI
45
import WidgetKit
56

67
struct WidgetCustomEntry: TimelineEntry {
@@ -14,6 +15,12 @@ struct WidgetCustomEntry: TimelineEntry {
1415
struct ItemState: Codable {
1516
let value: String
1617
let domainState: Domain.State?
18+
let hexColor: String?
19+
20+
var color: Color? {
21+
guard let hexColor else { return nil }
22+
return Color(hex: hexColor)
23+
}
1724
}
1825
}
1926

@@ -157,7 +164,8 @@ struct WidgetCustomTimelineProvider: AppIntentTimelineProvider {
157164
states[item] =
158165
.init(
159166
value: "\(StatePrecision.adjustPrecision(serverId: serverId, entityId: entityId, stateValue: state.value)) \(state.unitOfMeasurement ?? "")",
160-
domainState: state.domainState
167+
domainState: state.domainState,
168+
hexColor: state.color?.hex()
161169
)
162170
} else {
163171
Current.Log

Sources/Shared/ControlEntityProvider.swift

Lines changed: 114 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22
import GRDB
33
import HAKit
4+
import SwiftUI
45

56
public final class ControlEntityProvider {
67
public enum States: String {
@@ -16,11 +17,13 @@ public final class ControlEntityProvider {
1617
public let value: String
1718
public let unitOfMeasurement: String?
1819
public let domainState: Domain.State?
20+
public let color: Color?
1921

20-
public init(value: String, unitOfMeasurement: String?, domainState: Domain.State?) {
22+
public init(value: String, unitOfMeasurement: String?, domainState: Domain.State?, color: Color? = nil) {
2123
self.value = value
2224
self.unitOfMeasurement = unitOfMeasurement
2325
self.domainState = domainState
26+
self.color = color
2427
}
2528
}
2629

@@ -124,56 +127,140 @@ public final class ControlEntityProvider {
124127
}
125128
}
126129

127-
var data: HAData?
128-
switch result {
129-
case let .success(resultData):
130-
data = resultData
131-
case let .failure(error):
132-
Current.Log.error("Failed to get state: \(error)")
133-
return nil
134-
}
135-
136-
guard let data else {
130+
guard let data = try? result.get() else {
131+
if case let .failure(error) = result {
132+
Current.Log.error("Failed to get state: \(error)")
133+
}
137134
return nil
138135
}
139136

140-
var state: [String: Any]?
141-
switch data {
142-
case let .dictionary(response):
143-
state = response
144-
default:
137+
guard case let .dictionary(state) = data else {
145138
Current.Log.error("Failed to get state bad response data")
146139
return nil
147140
}
148141

149-
var stateValue = (state?["state"] as? String) ?? "N/A"
142+
var stateValue = (state["state"] as? String) ?? "N/A"
150143
stateValue = StatePrecision.adjustPrecision(
151144
serverId: server.identifier.rawValue,
152145
entityId: entityId,
153146
stateValue: stateValue
154147
)
155-
let unitOfMeasurement = (state?["attributes"] as? [String: Any])?["unit_of_measurement"] as? String
156148
stateValue = stateValue.capitalizedFirst
157149

150+
let attributes = state["attributes"] as? [String: Any]
151+
let colorAttributes = parseColorAttributes(from: attributes)
152+
let unitOfMeasurement = attributes?["unit_of_measurement"] as? String
153+
154+
return buildState(
155+
entityId: entityId,
156+
stateValue: stateValue,
157+
attributes: attributes,
158+
colorAttributes: colorAttributes,
159+
unitOfMeasurement: unitOfMeasurement
160+
)
161+
}
162+
163+
private func parseColorAttributes(from attributes: [String: Any]?) -> (
164+
colorMode: String?,
165+
rgbColor: [Int]?,
166+
hsColor: [Double]?
167+
) {
168+
guard let attributes else {
169+
return (nil, nil, nil)
170+
}
171+
172+
let colorMode = attributes["color_mode"] as? String
173+
let rgbColor = parseRGBColor(from: attributes["rgb_color"])
174+
let hsColor = parseHSColor(from: attributes["hs_color"])
175+
176+
return (colorMode, rgbColor, hsColor)
177+
}
178+
179+
private func parseRGBColor(from value: Any?) -> [Int]? {
180+
if let rgb = value as? [Int] {
181+
return rgb
182+
}
183+
if let rgbAny = value as? [Any] {
184+
let ints = rgbAny.compactMap { $0 as? Int }
185+
return ints.count == 3 ? ints : nil
186+
}
187+
return nil
188+
}
189+
190+
private func parseHSColor(from value: Any?) -> [Double]? {
191+
if let hs = value as? [Double] {
192+
return hs
193+
}
194+
if let hsAny = value as? [Any] {
195+
let doubles = hsAny.compactMap { value -> Double? in
196+
if let d = value as? Double { return d }
197+
if let n = value as? NSNumber { return n.doubleValue }
198+
if let s = value as? String, let d = Double(s) { return d }
199+
return nil
200+
}
201+
return doubles.count >= 2 ? Array(doubles.prefix(2)) : nil
202+
}
203+
return nil
204+
}
205+
206+
private func buildState(
207+
entityId: String,
208+
stateValue: String,
209+
attributes: [String: Any]?,
210+
colorAttributes: (colorMode: String?, rgbColor: [Int]?, hsColor: [Double]?),
211+
unitOfMeasurement: String?
212+
) -> State {
158213
let domain = Domain(entityId: entityId)
159-
if let deviceClass = {
160-
let rawDeviceClass = (state?["attributes"] as? [String: Any])?["device_class"] as? String
161-
return DeviceClass(rawValue: rawDeviceClass ?? "")
162-
}(),
163-
let domainState = Domain.State(rawValue: stateValue.lowercased()),
164-
unitOfMeasurement == nil,
165-
let stateForDeviceClass = domain?.stateForDeviceClass(deviceClass, state: domainState) {
214+
let domainState = Domain.State(rawValue: stateValue.lowercased())
215+
216+
if let deviceClass = extractDeviceClass(from: attributes),
217+
let domainState,
218+
unitOfMeasurement == nil,
219+
let stateForDeviceClass = domain?.stateForDeviceClass(deviceClass, state: domainState) {
220+
let computedColor = computeIconColor(
221+
entityId: entityId,
222+
stateValue: stateValue,
223+
colorAttributes: colorAttributes
224+
)
166225
return .init(
167226
value: stateForDeviceClass,
168227
unitOfMeasurement: nil,
169-
domainState: domainState
228+
domainState: domainState,
229+
color: computedColor
170230
)
171231
} else {
232+
let computedColor = computeIconColor(
233+
entityId: entityId,
234+
stateValue: stateValue,
235+
colorAttributes: colorAttributes
236+
)
172237
return .init(
173238
value: stateValue,
174239
unitOfMeasurement: unitOfMeasurement,
175-
domainState: Domain.State(rawValue: stateValue)
240+
domainState: domainState,
241+
color: computedColor
176242
)
177243
}
178244
}
245+
246+
private func extractDeviceClass(from attributes: [String: Any]?) -> DeviceClass? {
247+
guard let rawDeviceClass = attributes?["device_class"] as? String else {
248+
return nil
249+
}
250+
return DeviceClass(rawValue: rawDeviceClass)
251+
}
252+
253+
private func computeIconColor(
254+
entityId: String,
255+
stateValue: String,
256+
colorAttributes: (colorMode: String?, rgbColor: [Int]?, hsColor: [Double]?)
257+
) -> Color? {
258+
EntityIconColorProvider.iconColor(
259+
domain: Domain(entityId: entityId) ?? .switch,
260+
state: stateValue.lowercased(),
261+
colorMode: colorAttributes.colorMode,
262+
rgbColor: colorAttributes.rgbColor,
263+
hsColor: colorAttributes.hsColor
264+
)
265+
}
179266
}

Sources/Shared/Domain/Domain.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ public enum Domain: String, CaseIterable {
4343

4444
case unknown
4545
case unavailable
46+
47+
/// States that represent an "active" condition
48+
public var isActive: Bool {
49+
Domain.activeStates.contains(self)
50+
}
4651
}
4752

4853
/// States that represent an "active" condition

0 commit comments

Comments
 (0)