Skip to content

Commit 899392a

Browse files
bgoncalCopilot
andauthored
Improve Gauge widget UI and refactor code (#4798)
<!-- 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. --> ## 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-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 75897de commit 899392a

11 files changed

Lines changed: 353 additions & 161 deletions

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 53 additions & 29 deletions
Large diffs are not rendered by default.
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import Shared
2+
import SwiftUI
3+
import WidgetKit
4+
5+
/// A circular gauge whose tinted arc fills only `0…value`, leaving the remainder as a dim track —
6+
/// matching Apple's Batteries widget. Used on the full-color Home Screen.
7+
@available(iOS 17.0, *)
8+
struct GaugeArcView: View {
9+
/// Gauge value in `0…1`.
10+
let value: Double
11+
/// Centered value label (e.g. "84%").
12+
var centerLabel: String?
13+
/// Optional label shown above the value (used by the single-label gauge type).
14+
var topLabel: String?
15+
/// Optional labels at the gauge's open ends (used by the normal gauge type).
16+
var minLabel: String?
17+
var maxLabel: String?
18+
/// Whether the arc should use the full circle range, used by the capacity gauge type.
19+
var usesFullCircleRange = false
20+
21+
/// Fraction of the full circle the gauge sweeps (270°), leaving a gap centered at the bottom.
22+
private static let sweep: CGFloat = 0.75
23+
/// Stroke width as a fraction of the view's smaller dimension.
24+
private static let lineWidthRatio: CGFloat = 0.1
25+
/// Opacity of the unfilled track.
26+
private static let trackOpacity: CGFloat = 0.25
27+
/// Rotation that places the 270° sweep's gap at the bottom.
28+
private static let arcRotationDegrees: Double = 135
29+
/// Minimum and maximum values for the normalized gauge fill.
30+
private static let minimumValue: CGFloat = 0
31+
private static let maximumValue: CGFloat = 1
32+
/// Fixed drawing size. The parent widget scales this view to match the system-small tile.
33+
private static let gaugeSize: CGFloat = 150
34+
/// Nudge the whole composition down so the open-bottom 270° arc reads as vertically centered.
35+
/// The arc is top-weighted — its drawn span reaches the top but stops short of the bottom.
36+
private static let verticalCenteringOffset: CGFloat = 10
37+
private static let centerLabelSpacing: CGFloat = 3
38+
private static let logoSize: CGFloat = 22
39+
private static let minimumLabelScaleFactor: CGFloat = 0.5
40+
private static let endLabelsContainerPadding: CGFloat = 28
41+
private static let labelLineLimit = 1
42+
43+
var body: some View {
44+
ZStack {
45+
trackArc
46+
.stroke(.tint.opacity(Self.trackOpacity), style: strokeStyle(Self.lineWidth))
47+
valueArc
48+
.stroke(.tint, style: strokeStyle(Self.lineWidth))
49+
50+
centerLabels
51+
endLabels
52+
}
53+
.frame(width: Self.gaugeSize, height: Self.gaugeSize)
54+
.overlay(alignment: .bottom) {
55+
if showsLogoAsBottomOverlay {
56+
homeAssistantLogo
57+
.offset(y: -DesignSystem.Spaces.one)
58+
}
59+
}
60+
.offset(y: verticalCenteringOffset)
61+
}
62+
63+
private static var lineWidth: CGFloat {
64+
gaugeSize * lineWidthRatio
65+
}
66+
67+
private var clampedValue: CGFloat {
68+
max(Self.minimumValue, min(Self.maximumValue, CGFloat(value)))
69+
}
70+
71+
private var trackArc: some Shape {
72+
arc(to: Self.maximumValue)
73+
}
74+
75+
private var valueArc: some Shape {
76+
arc(to: clampedValue)
77+
}
78+
79+
/// An arc sweeping clockwise from the bottom-left up and over to the bottom-right, gap centered
80+
/// at the bottom. `fraction` (0…1) scales how far along the 270° sweep it travels.
81+
private func arc(to fraction: CGFloat) -> some Shape {
82+
Circle()
83+
.trim(from: Self.minimumValue, to: arcRangeEnd(for: fraction))
84+
.rotation(.degrees(rotationDegrees))
85+
}
86+
87+
private func arcRangeEnd(for fraction: CGFloat) -> CGFloat {
88+
usesFullCircleRange ? fraction : Self.sweep * fraction
89+
}
90+
91+
private var rotationDegrees: Double {
92+
usesFullCircleRange ? -90 : Self.arcRotationDegrees
93+
}
94+
95+
private var verticalCenteringOffset: CGFloat {
96+
usesFullCircleRange ? .zero : Self.verticalCenteringOffset
97+
}
98+
99+
private func strokeStyle(_ lineWidth: CGFloat) -> StrokeStyle {
100+
StrokeStyle(lineWidth: lineWidth, lineCap: .round)
101+
}
102+
103+
@ViewBuilder private var centerLabels: some View {
104+
VStack(spacing: Self.centerLabelSpacing) {
105+
if showsLogoInLabelStack {
106+
homeAssistantLogo
107+
}
108+
if let topLabel {
109+
Text(verbatim: topLabel)
110+
.font(DesignSystem.Font.title3)
111+
.foregroundStyle(.secondary)
112+
}
113+
if let centerLabel {
114+
Text(verbatim: centerLabel)
115+
.font(DesignSystem.Font.largeTitle.bold())
116+
.foregroundStyle(.primary)
117+
}
118+
}
119+
.lineLimit(Self.labelLineLimit)
120+
.minimumScaleFactor(Self.minimumLabelScaleFactor)
121+
.foregroundStyle(.primary)
122+
}
123+
124+
private var showsLogoInLabelStack: Bool {
125+
usesFullCircleRange && topLabel == nil && centerLabel != nil
126+
}
127+
128+
private var showsLogoAsBottomOverlay: Bool {
129+
!usesFullCircleRange
130+
}
131+
132+
private var homeAssistantLogo: some View {
133+
Image(.logo)
134+
.resizable()
135+
.aspectRatio(contentMode: .fit)
136+
.frame(width: Self.logoSize, height: Self.logoSize)
137+
.accessibilityHidden(true)
138+
}
139+
140+
@ViewBuilder private var endLabels: some View {
141+
if minLabel != nil || maxLabel != nil {
142+
HStack(spacing: .zero) {
143+
if let minLabel {
144+
endLabel(minLabel)
145+
.frame(maxWidth: .infinity, alignment: .leading)
146+
}
147+
if minLabel != nil, maxLabel != nil {
148+
Spacer()
149+
}
150+
if let maxLabel {
151+
endLabel(maxLabel)
152+
.frame(maxWidth: .infinity, alignment: .trailing)
153+
}
154+
}
155+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
156+
.padding(Self.endLabelsContainerPadding)
157+
.clipShape(.circle)
158+
.foregroundStyle(.primary)
159+
}
160+
}
161+
162+
private func endLabel(_ text: String) -> some View {
163+
Text(verbatim: text)
164+
.font(DesignSystem.Font.body)
165+
.foregroundStyle(.secondary)
166+
}
167+
}
168+
169+
@available(iOS 17, *)
170+
#Preview(as: .systemSmall, widget: {
171+
WidgetGauge()
172+
}, timeline: {
173+
WidgetGaugeEntry(
174+
gaugeType: .normal,
175+
value: 0.67,
176+
valueLabel: "67%",
177+
label: nil,
178+
min: "0",
179+
max: "100",
180+
runScript: false,
181+
script: nil,
182+
showConfirmationNotification: true
183+
)
184+
})
185+
186+
@available(iOS 17, *)
187+
#Preview(as: .systemSmall, widget: {
188+
WidgetGauge()
189+
}, timeline: {
190+
WidgetGaugeEntry(
191+
gaugeType: .singleLabel,
192+
value: 0.67,
193+
valueLabel: "67%",
194+
label: "Battery",
195+
min: nil,
196+
max: nil,
197+
runScript: false,
198+
script: nil,
199+
showConfirmationNotification: true
200+
)
201+
})
202+
203+
@available(iOS 17, *)
204+
#Preview(as: .systemSmall, widget: {
205+
WidgetGauge()
206+
}, timeline: {
207+
WidgetGaugeEntry(
208+
gaugeType: .capacity,
209+
value: 0.67,
210+
valueLabel: "100%",
211+
label: nil,
212+
min: "0",
213+
max: "100",
214+
runScript: false,
215+
script: nil,
216+
showConfirmationNotification: true
217+
)
218+
})
219+
220+
@available(iOS 17, *)
221+
#Preview(as: .accessoryCircular, widget: {
222+
WidgetGauge()
223+
}, timeline: {
224+
WidgetGaugeEntry(
225+
gaugeType: .normal,
226+
value: 0.67,
227+
valueLabel: "67%",
228+
label: nil,
229+
min: "0",
230+
max: "100",
231+
runScript: false,
232+
script: nil,
233+
showConfirmationNotification: true
234+
)
235+
})

Sources/Extensions/Widgets/Lockscreen/Gauge/WidgetGauge.swift

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ struct WidgetGauge: Widget {
2626
.configurationDisplayName(L10n.Widgets.Gauge.title)
2727
.description(L10n.Widgets.Gauge.descriptionWithWarning)
2828
.supportedFamilies(WidgetGaugeSupportedFamilies.families)
29-
.disfavoredInCarPlayIfAvailable(for: WidgetGaugeSupportedFamilies.families)
3029
}
3130

3231
private func intent(for entry: WidgetGaugeEntry) -> ScriptAppIntent? {
@@ -43,3 +42,54 @@ struct WidgetGauge: Widget {
4342
enum WidgetGaugeSupportedFamilies {
4443
static let families: [WidgetFamily] = [.accessoryCircular, .systemSmall]
4544
}
45+
46+
@available(iOS 17, *)
47+
#Preview(as: .systemSmall, widget: {
48+
WidgetGauge()
49+
}, timeline: {
50+
WidgetGaugeEntry(
51+
gaugeType: .normal,
52+
value: 0.67,
53+
valueLabel: "67%",
54+
label: nil,
55+
min: "0",
56+
max: "100",
57+
runScript: false,
58+
script: nil,
59+
showConfirmationNotification: true
60+
)
61+
})
62+
63+
@available(iOS 17, *)
64+
#Preview(as: .systemSmall, widget: {
65+
WidgetGauge()
66+
}, timeline: {
67+
WidgetGaugeEntry(
68+
gaugeType: .capacity,
69+
value: 0.67,
70+
valueLabel: "67%",
71+
label: nil,
72+
min: "0",
73+
max: "100",
74+
runScript: false,
75+
script: nil,
76+
showConfirmationNotification: true
77+
)
78+
})
79+
80+
@available(iOS 17, *)
81+
#Preview(as: .accessoryCircular, widget: {
82+
WidgetGauge()
83+
}, timeline: {
84+
WidgetGaugeEntry(
85+
gaugeType: .normal,
86+
value: 0.67,
87+
valueLabel: "67%",
88+
label: nil,
89+
min: "0",
90+
max: "100",
91+
runScript: false,
92+
script: nil,
93+
showConfirmationNotification: true
94+
)
95+
})

0 commit comments

Comments
 (0)