my swift app for VT gyms
gymtracker.jackhannon.net
1// GymTrackerLockscreenWidget.swift
2// Lock screen accessory widgets: circular (per gym) and rectangular (all three).
3// Circular uses the branded segmented progress ring.
4
5import WidgetKit
6import SwiftUI
7
8// MARK: - Lock Screen Segmented Circular (branded segment style, compact for accessory)
9struct LockScreenSegmentedCircularView: View {
10 let percentage: Double
11 let isEmpty: Bool
12 let totalSegments: Int
13 var label: String? = nil // e.g. "WM", "MC", "BW" – shown below the number
14 var size: CGFloat = 50
15 var lineWidth: CGFloat = 6
16 var fontScale: CGFloat = 0.28
17 var segmentSpacing: Double = 1.5
18
19 private var segmentAngle: Double {
20 (360.0 / Double(totalSegments)) - segmentSpacing
21 }
22
23 var body: some View {
24 GeometryReader { geo in
25 let s = min(geo.size.width, geo.size.height)
26 ZStack {
27 // Background segments (tinted track: segment color 0.28, empty 0.22)
28 ForEach(0..<totalSegments, id: \.self) { index in
29 SegmentShape(
30 startAngle: segmentStartAngle(for: index),
31 endAngle: segmentStartAngle(for: index) + segmentAngle,
32 lineWidth: lineWidth
33 )
34 .stroke(lineWidth: lineWidth)
35 .foregroundColor(isEmpty ? Color.gray.opacity(0.20) : segmentColor(index).opacity(0.20))
36 }
37
38 // Foreground segments (filled by percentage)
39 ForEach(0..<totalSegments, id: \.self) { index in
40 SegmentShape(
41 startAngle: segmentStartAngle(for: index),
42 endAngle: segmentStartAngle(for: index) + segmentAngle,
43 lineWidth: lineWidth
44 )
45 .stroke(lineWidth: lineWidth)
46 .foregroundColor(isEmpty ? Color.gray : segmentColor(index))
47 .opacity(opacityForSegment(index: index, adjustedPercentage: percentage / 100.0))
48 }
49
50 // Center: number and optional gym label
51 VStack(spacing: 1) {
52 Group {
53 if isEmpty {
54 Text("—")
55 .font(.system(size: s * fontScale, weight: .bold))
56 .foregroundColor(.secondary)
57 } else {
58 (Text("\(Int(percentage))")
59 .font(.system(size: s * fontScale, weight: .bold))
60 .foregroundColor(.primary)
61 + Text("%")
62 .font(.system(size: s * fontScale * 0.6, weight: .bold))
63 .foregroundColor(.primary))
64 }
65 }
66 .minimumScaleFactor(0.6)
67 .lineLimit(1)
68 if let label {
69 Text(label)
70 .font(.system(size: 8, weight: .semibold))
71 .foregroundColor(.secondary)
72 .lineLimit(1)
73 }
74 }
75 }
76 .frame(width: s, height: s)
77 }
78 .frame(maxWidth: .infinity, maxHeight: .infinity)
79 }
80
81 private func segmentStartAngle(for index: Int) -> Double {
82 (Double(index) * (segmentAngle + segmentSpacing)) - 90
83 }
84
85 private func segmentColor(_ index: Int) -> Color {
86 index < totalSegments / 2 ? Color("WidgetCustomGreen") :
87 index < (totalSegments * 3) / 4 ? Color("WidgetCustomOrange") : Color("WidgetCustomMaroon")
88 }
89
90 private func opacityForSegment(index: Int, adjustedPercentage: Double) -> Double {
91 let segmentProgress = Double(index) / Double(totalSegments)
92 let nextSegmentProgress = Double(index + 1) / Double(totalSegments)
93 if adjustedPercentage >= nextSegmentProgress { return 1.0 }
94 if adjustedPercentage > segmentProgress {
95 return max((adjustedPercentage - segmentProgress) / (nextSegmentProgress - segmentProgress), 0.3)
96 }
97 return 0.0
98 }
99}
100
101// MARK: - Circular Widget Views (one per gym)
102struct McComasCircularWidgetView: View {
103 let entry: UnifiedGymTrackerEntry
104
105 var body: some View {
106 let pct = entry.maxMcComasCapacity > 0
107 ? (Double(entry.mcComasOccupancy) / Double(entry.maxMcComasCapacity)) * 100
108 : 0
109 LockScreenSegmentedCircularView(percentage: pct, isEmpty: entry.mcComasOccupancy == 0, totalSegments: 12, label: "MC")
110 }
111}
112
113struct WarMemorialCircularWidgetView: View {
114 let entry: UnifiedGymTrackerEntry
115
116 var body: some View {
117 let pct = entry.maxWarMemorialCapacity > 0
118 ? (Double(entry.warMemorialOccupancy) / Double(entry.maxWarMemorialCapacity)) * 100
119 : 0
120 LockScreenSegmentedCircularView(percentage: pct, isEmpty: entry.warMemorialOccupancy == 0, totalSegments: 12, label: "WM")
121 }
122}
123
124struct BoulderingWallCircularWidgetView: View {
125 let entry: UnifiedGymTrackerEntry
126
127 var body: some View {
128 let pct = entry.maxBoulderingWallCapacity > 0
129 ? (Double(entry.boulderingWallOccupancy) / Double(entry.maxBoulderingWallCapacity)) * 100
130 : 0
131 LockScreenSegmentedCircularView(percentage: pct, isEmpty: entry.boulderingWallOccupancy == 0, totalSegments: 8, label: "BW")
132 }
133}
134
135// MARK: - Rectangular Lock Screen Widget (all three gyms)
136struct RectangularLockScreenWidgetView: View {
137 let entry: UnifiedGymTrackerEntry
138
139 var body: some View {
140 VStack(alignment: .leading, spacing: 5) {
141 row(label: "War Memorial", occupancy: entry.warMemorialOccupancy, max: entry.maxWarMemorialCapacity)
142 row(label: "McComas", occupancy: entry.mcComasOccupancy, max: entry.maxMcComasCapacity)
143 row(label: "Bouldering Wall", occupancy: entry.boulderingWallOccupancy, max: entry.maxBoulderingWallCapacity)
144 }
145 .frame(maxWidth: .infinity, alignment: .leading)
146 .padding(.horizontal, 4)
147 .padding(.vertical, 6)
148 }
149
150 private func row(label: String, occupancy: Int, max: Int) -> some View {
151 HStack(alignment: .center, spacing: 6) {
152 Text(label)
153 .font(.system(size: 11, weight: .semibold))
154 .foregroundColor(.primary)
155 .lineLimit(1)
156 Spacer(minLength: 2)
157 (Text("\(occupancy.abbreviatedCount)")
158 .font(.system(size: 11, weight: .medium))
159 .foregroundColor(.primary)
160 + Text("/\(max.abbreviatedCount)")
161 .font(.system(size: 11, weight: .medium))
162 .foregroundColor(.secondary))
163 .lineLimit(1)
164 }
165 }
166}
167
168// MARK: - Widgets
169
170struct McComasCircularWidget: Widget {
171 let kind: String = "McComasCircularWidget"
172
173 var body: some WidgetConfiguration {
174 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in
175 McComasCircularWidgetView(entry: entry)
176 .containerBackground(for: .widget) { Color.clear }
177 }
178 .configurationDisplayName("McComas")
179 .description("McComas gym occupancy")
180 .supportedFamilies([.accessoryCircular])
181 .contentMarginsDisabled()
182 }
183}
184
185struct WarMemorialCircularWidget: Widget {
186 let kind: String = "WarMemorialCircularWidget"
187
188 var body: some WidgetConfiguration {
189 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in
190 WarMemorialCircularWidgetView(entry: entry)
191 .containerBackground(for: .widget) { Color.clear }
192 }
193 .configurationDisplayName("War Memorial")
194 .description("War Memorial gym occupancy")
195 .supportedFamilies([.accessoryCircular])
196 .contentMarginsDisabled()
197 }
198}
199
200struct BoulderingWallCircularWidget: Widget {
201 let kind: String = "BoulderingWallCircularWidget"
202
203 var body: some WidgetConfiguration {
204 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in
205 BoulderingWallCircularWidgetView(entry: entry)
206 .containerBackground(for: .widget) { Color.clear }
207 }
208 .configurationDisplayName("Bouldering Wall")
209 .description("Bouldering wall occupancy")
210 .supportedFamilies([.accessoryCircular])
211 .contentMarginsDisabled()
212 }
213}
214
215struct GymTrackerRectangularWidget: Widget {
216 let kind: String = "GymTrackerRectangularWidget"
217
218 var body: some WidgetConfiguration {
219 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in
220 RectangularLockScreenWidgetView(entry: entry)
221 .containerBackground(for: .widget) { Color.clear }
222 }
223 .configurationDisplayName("VT Gyms")
224 .description("War Memorial, McComas, and Bouldering occupancy")
225 .supportedFamilies([.accessoryRectangular])
226 .contentMarginsDisabled()
227 }
228}