|
| 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 | +}) |
0 commit comments