my swift app for VT gyms gymtracker.jackhannon.net

Enhance occupancy display and widget integration for bouldering wall

- Introduced abbreviated count formatting for occupancy and max capacity in OccupancyCard and WatchGymCardView.
- Updated GymOccupancyFetcher to include bouldering wall occupancy data.
- Enhanced UnifiedGymTrackerProvider to support bouldering wall occupancy in widget entries.
- Added new circular widget for bouldering wall occupancy and updated existing widgets to include it.
- Refactored widget views to maintain consistent design and functionality across all gym facilities.

+397 -376
+2 -2
Gym Tracker (RC)/OccupancyCard.swift
··· 15 VStack(alignment: .leading, spacing: 8) { 16 HStack { 17 // Display current occupancy and max capacity 18 - Text("\(occupancy)") 19 .font(.subheadline) 20 .bold() 21 + 22 - Text(" / \(maxCapacity)") 23 .font(.subheadline) 24 .foregroundColor(.secondary) 25
··· 15 VStack(alignment: .leading, spacing: 8) { 16 HStack { 17 // Display current occupancy and max capacity 18 + Text("\(occupancy.abbreviatedCount)") 19 .font(.subheadline) 20 .bold() 21 + 22 + Text(" / \(maxCapacity.abbreviatedCount)") 23 .font(.subheadline) 24 .foregroundColor(.secondary) 25
+4 -5
Gym Tracker (RC)/SegmentedProgressBar.swift
··· 78 } 79 } 80 81 - // Determines the background color for a segment based on its index and empty state 82 private func backgroundColor(for index: Int) -> Color { 83 if isEmpty { 84 - // Greyed-out background when empty 85 - return Color.gray.opacity(0.2) 86 } else { 87 let fraction = CGFloat(index + 1) / CGFloat(totalSegments) 88 - let color = occupancyColor(for: fraction) 89 - return color.opacity(0.2) 90 } 91 } 92
··· 78 } 79 } 80 81 + // Determines the background color for a segment based on its index and empty state. 82 + // Uses a soft tint of the occupancy colors (green/orange/maroon) so the track feels intentional, not flat grey. 83 private func backgroundColor(for index: Int) -> Color { 84 if isEmpty { 85 + return Color("CustomGreen").opacity(0.22) 86 } else { 87 let fraction = CGFloat(index + 1) / CGFloat(totalSegments) 88 + return occupancyColor(for: fraction).opacity(0.28) 89 } 90 } 91
+9
Gym Tracker (RC)/Services/Constants.swift
··· 3 4 import Foundation 5 6 struct Constants { 7 // Facility IDs 8 static let mcComasFacilityId = "da73849e-434d-415f-975a-4f9e799b9c39"
··· 3 4 import Foundation 5 6 + extension Int { 7 + /// e.g. 234 → "234", 1200 → "1.2k", 12345 → "12.3k" 8 + var abbreviatedCount: String { 9 + if self < 1000 { return "\(self)" } 10 + if self < 1_000_000 { return String(format: "%.1fk", Double(self) / 1000) } 11 + return String(format: "%.1fM", Double(self) / 1_000_000) 12 + } 13 + } 14 + 15 struct Constants { 16 // Facility IDs 17 static let mcComasFacilityId = "da73849e-434d-415f-975a-4f9e799b9c39"
+5 -4
Gym Tracker (RC)/Services/GymOccupancyFetcher.swift
··· 10 return URLSession(configuration: c) 11 }() 12 13 - /// Widget: McComas + War Memorial only. Returns (occupancy, remaining) internally; Task 2 can expose for GymOccupancyData. 14 - static func fetchForWidget() async -> (mcComas: Int?, warMemorial: Int?) { 15 async let mc = fetchOne(facilityId: Constants.mcComasFacilityId) 16 async let wm = fetchOne(facilityId: Constants.warMemorialFacilityId) 17 - let (m, w) = await (mc, wm) 18 - return (m?.occupancy, w?.occupancy) 19 } 20 21 /// Main app: all three facilities. Returns (occupancy, remaining) per facility.
··· 10 return URLSession(configuration: c) 11 }() 12 13 + /// Widget: McComas, War Memorial, and Bouldering Wall. Returns occupancy per facility. 14 + static func fetchForWidget() async -> (mcComas: Int?, warMemorial: Int?, boulderingWall: Int?) { 15 async let mc = fetchOne(facilityId: Constants.mcComasFacilityId) 16 async let wm = fetchOne(facilityId: Constants.warMemorialFacilityId) 17 + async let bw = fetchOne(facilityId: Constants.boulderingWallFacilityId) 18 + let (m, w, b) = await (mc, wm, bw) 19 + return (m?.occupancy, w?.occupancy, b?.occupancy) 20 } 21 22 /// Main app: all three facilities. Returns (occupancy, remaining) per facility.
+17 -6
Gym Tracker (RC)/Services/UnifiedGymTrackerProvider.swift
··· 5 let date: Date 6 let mcComasOccupancy: Int 7 let warMemorialOccupancy: Int 8 let maxMcComasCapacity: Int 9 let maxWarMemorialCapacity: Int 10 } 11 12 struct UnifiedGymTrackerProvider: TimelineProvider { ··· 16 date: Date(), 17 mcComasOccupancy: 300, 18 warMemorialOccupancy: 600, 19 maxMcComasCapacity: Constants.mcComasMaxCapacity, 20 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 21 ) 22 } 23 ··· 27 28 func getTimeline(in context: Context, completion: @escaping (Timeline<UnifiedGymTrackerEntry>) -> Void) { 29 Task { 30 - let (mc, wm) = await GymOccupancyFetcher.fetchForWidget() 31 let shared = UserDefaults(suiteName: Constants.appGroupID) 32 let mcFinal = mc ?? shared?.integer(forKey: "mcComasOccupancy") ?? 0 33 let wmFinal = wm ?? shared?.integer(forKey: "warMemorialOccupancy") ?? 0 34 35 if mc != nil { shared?.set(mc!, forKey: "mcComasOccupancy") } 36 if wm != nil { shared?.set(wm!, forKey: "warMemorialOccupancy") } 37 - if mc != nil || wm != nil { shared?.set(Date(), forKey: "lastFetchDate") } 38 39 let entry = UnifiedGymTrackerEntry( 40 date: Date(), 41 mcComasOccupancy: mcFinal, 42 warMemorialOccupancy: wmFinal, 43 maxMcComasCapacity: Constants.mcComasMaxCapacity, 44 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 45 ) 46 let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(15 * 60) 47 completion(Timeline(entries: [entry], policy: .after(next))) ··· 52 let sharedDefaults = UserDefaults(suiteName: Constants.appGroupID) 53 let mcOccupancy = sharedDefaults?.integer(forKey: "mcComasOccupancy") ?? 0 54 let wmOccupancy = sharedDefaults?.integer(forKey: "warMemorialOccupancy") ?? 0 55 - 56 return UnifiedGymTrackerEntry( 57 date: Date(), 58 mcComasOccupancy: mcOccupancy, 59 warMemorialOccupancy: wmOccupancy, 60 maxMcComasCapacity: Constants.mcComasMaxCapacity, 61 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 62 ) 63 } 64 }
··· 5 let date: Date 6 let mcComasOccupancy: Int 7 let warMemorialOccupancy: Int 8 + let boulderingWallOccupancy: Int 9 let maxMcComasCapacity: Int 10 let maxWarMemorialCapacity: Int 11 + let maxBoulderingWallCapacity: Int 12 } 13 14 struct UnifiedGymTrackerProvider: TimelineProvider { ··· 18 date: Date(), 19 mcComasOccupancy: 300, 20 warMemorialOccupancy: 600, 21 + boulderingWallOccupancy: 4, 22 maxMcComasCapacity: Constants.mcComasMaxCapacity, 23 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 24 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 25 ) 26 } 27 ··· 31 32 func getTimeline(in context: Context, completion: @escaping (Timeline<UnifiedGymTrackerEntry>) -> Void) { 33 Task { 34 + let (mc, wm, bw) = await GymOccupancyFetcher.fetchForWidget() 35 let shared = UserDefaults(suiteName: Constants.appGroupID) 36 let mcFinal = mc ?? shared?.integer(forKey: "mcComasOccupancy") ?? 0 37 let wmFinal = wm ?? shared?.integer(forKey: "warMemorialOccupancy") ?? 0 38 + let bwFinal = bw ?? shared?.integer(forKey: "boulderingWallOccupancy") ?? 0 39 40 if mc != nil { shared?.set(mc!, forKey: "mcComasOccupancy") } 41 if wm != nil { shared?.set(wm!, forKey: "warMemorialOccupancy") } 42 + if bw != nil { shared?.set(bw!, forKey: "boulderingWallOccupancy") } 43 + if mc != nil || wm != nil || bw != nil { shared?.set(Date(), forKey: "lastFetchDate") } 44 45 let entry = UnifiedGymTrackerEntry( 46 date: Date(), 47 mcComasOccupancy: mcFinal, 48 warMemorialOccupancy: wmFinal, 49 + boulderingWallOccupancy: bwFinal, 50 maxMcComasCapacity: Constants.mcComasMaxCapacity, 51 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 52 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 53 ) 54 let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(15 * 60) 55 completion(Timeline(entries: [entry], policy: .after(next))) ··· 60 let sharedDefaults = UserDefaults(suiteName: Constants.appGroupID) 61 let mcOccupancy = sharedDefaults?.integer(forKey: "mcComasOccupancy") ?? 0 62 let wmOccupancy = sharedDefaults?.integer(forKey: "warMemorialOccupancy") ?? 0 63 + let bwOccupancy = sharedDefaults?.integer(forKey: "boulderingWallOccupancy") ?? 0 64 + 65 return UnifiedGymTrackerEntry( 66 date: Date(), 67 mcComasOccupancy: mcOccupancy, 68 warMemorialOccupancy: wmOccupancy, 69 + boulderingWallOccupancy: bwOccupancy, 70 maxMcComasCapacity: Constants.mcComasMaxCapacity, 71 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 72 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 73 ) 74 } 75 }
+4 -2
GymTrackerComplications/AppIntent.swift
··· 22 enum GymOption: String, AppEnum { 23 case mcComas = "McComas Hall" 24 case warMemorial = "War Memorial Hall" 25 - 26 static var typeDisplayRepresentation: TypeDisplayRepresentation { 27 "Gym Options" 28 } ··· 30 static var caseDisplayRepresentations: [GymOption: DisplayRepresentation] { 31 [ 32 .mcComas: "McComas Hall", 33 - .warMemorial: "War Memorial Hall" 34 ] 35 } 36 }
··· 22 enum GymOption: String, AppEnum { 23 case mcComas = "McComas Hall" 24 case warMemorial = "War Memorial Hall" 25 + case boulderingWall = "Bouldering Wall" 26 + 27 static var typeDisplayRepresentation: TypeDisplayRepresentation { 28 "Gym Options" 29 } ··· 31 static var caseDisplayRepresentations: [GymOption: DisplayRepresentation] { 32 [ 33 .mcComas: "McComas Hall", 34 + .warMemorial: "War Memorial Hall", 35 + .boulderingWall: "Bouldering Wall" 36 ] 37 } 38 }
+2 -2
GymTrackerWatch Watch App/WatchCircularProgressView.swift
··· 19 20 var body: some View { 21 ZStack { 22 - // Background segments 23 ForEach(0..<totalSegments, id: \.self) { index in 24 WatchSegmentShape( 25 startAngle: segmentStartAngle(for: index), ··· 27 lineWidth: lineWidth 28 ) 29 .stroke(lineWidth: lineWidth) 30 - .foregroundColor(isEmpty ? Color.gray.opacity(0.2) : segmentColor(index).opacity(0.2)) 31 } 32 33 // Foreground segments
··· 19 20 var body: some View { 21 ZStack { 22 + // Background segments (tinted track: segment color 0.28, empty 0.22) 23 ForEach(0..<totalSegments, id: \.self) { index in 24 WatchSegmentShape( 25 startAngle: segmentStartAngle(for: index), ··· 27 lineWidth: lineWidth 28 ) 29 .stroke(lineWidth: lineWidth) 30 + .foregroundColor(isEmpty ? Color("WatchCustomGreen").opacity(0.22) : segmentColor(index).opacity(0.28)) 31 } 32 33 // Foreground segments
+1 -1
GymTrackerWatch Watch App/WatchGymCardView.swift
··· 39 // Occupancy numbers 40 VStack(spacing: 4) { 41 if networkMonitor.isConnected { 42 - Text("\(occupancy) / \(maxCapacity)") 43 .font(.caption) 44 .foregroundColor(.secondary) 45 .multilineTextAlignment(.center)
··· 39 // Occupancy numbers 40 VStack(spacing: 4) { 41 if networkMonitor.isConnected { 42 + Text("\(occupancy.abbreviatedCount) / \(maxCapacity.abbreviatedCount)") 43 .font(.caption) 44 .foregroundColor(.secondary) 45 .multilineTextAlignment(.center)
+6 -3
GymTrackerWatch Watch App/WatchSegmentedProgressBar.swift
··· 25 } 26 } 27 28 - // Determine the color for each segment based on occupancy 29 private func colorForSegment(index: Int) -> Color { 30 let segmentThreshold = CGFloat(index + 1) / CGFloat(totalSegments) 31 if occupancyPercentage >= segmentThreshold { 32 - return progressColor(for: segmentThreshold) 33 } else { 34 - return Color.gray.opacity(0.3) 35 } 36 } 37
··· 25 } 26 } 27 28 + // Determine the color for each segment based on occupancy (tinted track: 0.28, empty 0.22) 29 private func colorForSegment(index: Int) -> Color { 30 let segmentThreshold = CGFloat(index + 1) / CGFloat(totalSegments) 31 + let color = progressColor(for: segmentThreshold) 32 if occupancyPercentage >= segmentThreshold { 33 + return color 34 } else { 35 + return occupancyPercentage == 0 36 + ? Color("WatchCustomGreen").opacity(0.22) 37 + : color.opacity(0.28) 38 } 39 } 40
+162 -214
GymTrackerWidget/GymTrackerLockscreenWidget.swift
··· 1 - // GymLockScreenWidget.swift 2 3 import WidgetKit 4 import SwiftUI 5 6 - // MARK: - McComas Circular Widget View 7 struct McComasCircularWidgetView: View { 8 let entry: UnifiedGymTrackerEntry 9 10 var body: some View { 11 - let percentage = (Double(entry.mcComasOccupancy) / Double(entry.maxMcComasCapacity)) * 100.0 12 - 13 - CompactCircularProgressViewLSW( 14 - percentage: percentage, 15 - size: 55, 16 - lineWidth: 6, 17 - fontScale: 0.25, 18 - totalSegments: 12, 19 - showPercentageText: true 20 - ) 21 - .containerBackground(.background, for: .widget) 22 - .tint(Color.blue) 23 } 24 } 25 26 - // MARK: - War Memorial Circular Widget View 27 struct WarMemorialCircularWidgetView: View { 28 let entry: UnifiedGymTrackerEntry 29 30 var body: some View { 31 - let percentage = (Double(entry.warMemorialOccupancy) / Double(entry.maxWarMemorialCapacity)) * 100.0 32 33 - CompactCircularProgressViewLSW( 34 - percentage: percentage, 35 - size: 55, 36 - lineWidth: 6, 37 - fontScale: 0.25, 38 - totalSegments: 12, 39 - showPercentageText: true 40 - ) 41 - .containerBackground(.background, for: .widget) 42 - .tint(Color.green) 43 } 44 } 45 46 - // MARK: - Rectangular LockScreen Widget View 47 struct RectangularLockScreenWidgetView: View { 48 let entry: UnifiedGymTrackerEntry 49 50 var body: some View { 51 - VStack(alignment: .leading, spacing: 8) { // Increased spacing for better readability 52 - // War Memorial Section (Moved to the top) 53 - HStack(alignment: .center, spacing: 8) { // Added spacing between progress and text 54 - // Circular Progress Bar moved to the left 55 - CompactCircularProgressViewLSW( 56 - percentage: calculatePercentage(occupancy: entry.warMemorialOccupancy, maxCapacity: entry.maxWarMemorialCapacity), 57 - size: 25, // Increased size 58 - lineWidth: 4, // Increased line width 59 - fontScale: 0.35, // Adjusted font scale 60 - totalSegments: 6, 61 - showPercentageText: true, // Enable percentage inside circle 62 - showPercentageSymbol: false // Disable the % symbol 63 - ) 64 - 65 - // Gym Texts 66 - VStack(alignment: .leading, spacing: 0) { 67 - Text("War Memorial") 68 - .font(.caption) 69 - .lineLimit(1) 70 - HStack(spacing: 0) { 71 - Text("\(entry.warMemorialOccupancy)") 72 - .font(.caption2) 73 - .bold() 74 - .lineLimit(1) 75 - Text(" / \(entry.maxWarMemorialCapacity)") 76 - .font(.caption2) 77 - .foregroundColor(.secondary) 78 - .lineLimit(1) 79 - } 80 - } 81 - } 82 - 83 - // McComas Section (Moved below War Memorial) 84 - HStack(alignment: .center, spacing: 8) { // Added spacing between progress and text 85 - // Circular Progress Bar moved to the left 86 - CompactCircularProgressViewLSW( 87 - percentage: calculatePercentage(occupancy: entry.mcComasOccupancy, maxCapacity: entry.maxMcComasCapacity), 88 - size: 25, // Increased size 89 - lineWidth: 4, // Increased line width 90 - fontScale: 0.35, // Adjusted font scale 91 - totalSegments: 6, 92 - showPercentageText: true, // Enable percentage inside circle 93 - showPercentageSymbol: false // Disable the % symbol 94 - ) 95 - 96 - // Gym Texts 97 - VStack(alignment: .leading, spacing: 0) { 98 - Text("McComas") 99 - .font(.caption) 100 - .lineLimit(1) 101 - HStack(spacing: 0) { 102 - Text("\(entry.mcComasOccupancy)") 103 - .font(.caption2) 104 - .bold() 105 - .lineLimit(1) 106 - Text(" / \(entry.maxMcComasCapacity)") 107 - .font(.caption2) 108 - .foregroundColor(.secondary) 109 - .lineLimit(1) 110 - } 111 - } 112 - } 113 } 114 - .frame(maxWidth: .infinity, alignment: .leading) // Align VStack to the leading edge 115 - .padding(.vertical, 4) // Adjusted vertical padding for better spacing 116 - .padding(.leading, 2) // Increased leading padding for left alignment 117 - .padding(.trailing, 4) // Reduced trailing padding 118 - .containerBackground(.background, for: .widget) 119 } 120 121 - private func calculatePercentage(occupancy: Int, maxCapacity: Int) -> Double { 122 - guard maxCapacity > 0 else { return 0 } 123 - return (Double(occupancy) / Double(maxCapacity)) * 100 124 } 125 } 126 127 - // MARK: - McComasCircularWidget 128 struct McComasCircularWidget: Widget { 129 let kind: String = "McComasCircularWidget" 130 ··· 132 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 133 McComasCircularWidgetView(entry: entry) 134 } 135 - .configurationDisplayName("McComas Gym") 136 - .description("Shows occupancy for McComas Gym.") 137 .supportedFamilies([.accessoryCircular]) 138 } 139 } 140 141 - // MARK: - WarMemorialCircularWidget 142 struct WarMemorialCircularWidget: Widget { 143 let kind: String = "WarMemorialCircularWidget" 144 ··· 146 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 147 WarMemorialCircularWidgetView(entry: entry) 148 } 149 - .configurationDisplayName("War Memorial Gym") 150 - .description("Shows occupancy for War Memorial Gym.") 151 .supportedFamilies([.accessoryCircular]) 152 } 153 } 154 155 - // MARK: - GymTrackerRectangularWidget 156 - struct GymTrackerRectangularWidget: Widget { 157 - let kind: String = "GymTrackerRectangularWidget" 158 159 var body: some WidgetConfiguration { 160 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 161 - RectangularLockScreenWidgetView(entry: entry) 162 } 163 - .configurationDisplayName("Gym Tracker") 164 - .description("Shows gym occupancy for McComas and War Memorial.") 165 - .supportedFamilies([.accessoryRectangular]) 166 } 167 } 168 - // MARK: - CompactCircularProgressViewLSW (with customizable line thickness and optional inner text) 169 - struct CompactCircularProgressViewLSW: View { 170 - let percentage: Double 171 - let size: CGFloat 172 - let lineWidth: CGFloat 173 - let fontScale: CGFloat 174 - let totalSegments: Int 175 - var showPercentageText: Bool // Existing parameter to control text display 176 - var showPercentageSymbol: Bool = true // New parameter to control % symbol display 177 - var segmentSpacing: Double = 2.0 178 - let minimumBrightness: Double = 0.3 179 180 - private var segmentAngle: Double { 181 - (360.0 / Double(totalSegments)) - segmentSpacing 182 - } 183 - 184 - var body: some View { 185 - ZStack { 186 - // Background segments (lighter, for unfilled segments) 187 - ForEach(0..<totalSegments, id: \.self) { index in 188 - let startAngle = segmentStartAngle(for: index) 189 - let endAngle = startAngle + segmentAngle 190 - 191 - CircularSegmentShape( 192 - startAngle: startAngle, 193 - endAngle: endAngle, 194 - lineWidth: lineWidth 195 - ) 196 - .stroke(lineWidth: lineWidth) 197 - .foregroundColor(self.segmentColor(for: index).opacity(0.2)) 198 - .frame(width: size, height: size) 199 - } 200 - 201 - // Foreground segments (filled based on progress) 202 - ForEach(0..<totalSegments, id: \.self) { index in 203 - let startAngle = segmentStartAngle(for: index) 204 - let endAngle = startAngle + segmentAngle 205 - 206 - CircularSegmentShape( 207 - startAngle: startAngle, 208 - endAngle: endAngle, 209 - lineWidth: lineWidth 210 - ) 211 - .stroke(lineWidth: lineWidth) 212 - .foregroundColor(self.segmentColor(for: index)) 213 - .opacity(self.opacityForSegment(index: index, adjustedPercentage: percentage / 100.0)) 214 - .frame(width: size, height: size) 215 - } 216 - 217 - // Center text (optional) 218 - if showPercentageText { 219 - Text(showPercentageSymbol ? String(format: "%.0f%%", percentage) : String(format: "%.0f", percentage)) 220 - .font(.system(size: size * fontScale, weight: .bold)) 221 - .foregroundColor(.primary) 222 - } 223 - } 224 - } 225 - 226 - private func segmentStartAngle(for index: Int) -> Double { 227 - (Double(index) * (segmentAngle + segmentSpacing)) - 90 228 - } 229 - 230 - private func segmentColor(for index: Int) -> Color { 231 - if index < totalSegments / 2 { 232 - return Color.green 233 - } else if index < (totalSegments * 3) / 4 { 234 - return Color.orange 235 - } else { 236 - return Color.red 237 - } 238 - } 239 - 240 - private func opacityForSegment(index: Int, adjustedPercentage: Double) -> Double { 241 - let segmentProgress = Double(index) / Double(totalSegments) 242 - let nextSegmentProgress = Double(index + 1) / Double(totalSegments) 243 244 - if adjustedPercentage >= nextSegmentProgress { 245 - return 1.0 246 - } else if adjustedPercentage > segmentProgress { 247 - let segmentFraction = (adjustedPercentage - segmentProgress) / (nextSegmentProgress - segmentProgress) 248 - return max(segmentFraction, minimumBrightness) 249 - } else { 250 - return 0.0 251 } 252 - } 253 - } 254 - 255 - // Shape for the circular segments 256 - struct CircularSegmentShape: Shape { 257 - let startAngle: Double 258 - let endAngle: Double 259 - let lineWidth: CGFloat 260 - 261 - func path(in rect: CGRect) -> Path { 262 - let radius = min(rect.width, rect.height) / 2 - (lineWidth / 2) 263 - let center = CGPoint(x: rect.midX, y: rect.midY) 264 - 265 - var path = Path() 266 - path.addArc( 267 - center: center, 268 - radius: radius, 269 - startAngle: Angle(degrees: startAngle), 270 - endAngle: Angle(degrees: endAngle), 271 - clockwise: false 272 - ) 273 - 274 - return path 275 } 276 }
··· 1 + // GymTrackerLockscreenWidget.swift 2 + // Lock screen accessory widgets: circular (per gym) and rectangular (all three). 3 + // Circular uses the branded segmented progress ring. 4 5 import WidgetKit 6 import SwiftUI 7 8 + // MARK: - Lock Screen Segmented Circular (branded segment style, compact for accessory) 9 + struct 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("WidgetCustomGreen").opacity(0.22) : segmentColor(index).opacity(0.28)) 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) 102 struct 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 113 struct 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 124 + struct 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) 136 struct 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 + 170 struct McComasCircularWidget: Widget { 171 let kind: String = "McComasCircularWidget" 172 ··· 174 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 175 McComasCircularWidgetView(entry: entry) 176 } 177 + .configurationDisplayName("McComas") 178 + .description("McComas gym occupancy") 179 .supportedFamilies([.accessoryCircular]) 180 + .contentMarginsDisabled() 181 } 182 } 183 184 struct WarMemorialCircularWidget: Widget { 185 let kind: String = "WarMemorialCircularWidget" 186 ··· 188 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 189 WarMemorialCircularWidgetView(entry: entry) 190 } 191 + .configurationDisplayName("War Memorial") 192 + .description("War Memorial gym occupancy") 193 .supportedFamilies([.accessoryCircular]) 194 + .contentMarginsDisabled() 195 } 196 } 197 198 + struct BoulderingWallCircularWidget: Widget { 199 + let kind: String = "BoulderingWallCircularWidget" 200 201 var body: some WidgetConfiguration { 202 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 203 + BoulderingWallCircularWidgetView(entry: entry) 204 } 205 + .configurationDisplayName("Bouldering Wall") 206 + .description("Bouldering wall occupancy") 207 + .supportedFamilies([.accessoryCircular]) 208 + .contentMarginsDisabled() 209 } 210 } 211 212 + struct GymTrackerRectangularWidget: Widget { 213 + let kind: String = "GymTrackerRectangularWidget" 214 215 + var body: some WidgetConfiguration { 216 + StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 217 + RectangularLockScreenWidgetView(entry: entry) 218 } 219 + .configurationDisplayName("VT Gyms") 220 + .description("War Memorial, McComas, and Bouldering occupancy") 221 + .supportedFamilies([.accessoryRectangular]) 222 + .contentMarginsDisabled() 223 } 224 }
+184 -137
GymTrackerWidget/GymTrackerWidget.swift
··· 9 @Environment(\.widgetRenderingMode) var widgetRenderingMode 10 11 var body: some View { 12 - switch widgetFamily { 13 - case .systemSmall: 14 - SmallWidgetView(entry: entry, widgetRenderingMode: widgetRenderingMode) 15 - case .systemMedium: 16 - MediumWidgetView(entry: entry, widgetRenderingMode: widgetRenderingMode) 17 - default: 18 - EmptyView() 19 } 20 } 21 } 22 23 - // MARK: - Small Widget 24 struct SmallWidgetView: View { 25 let entry: UnifiedGymTrackerEntry 26 let widgetRenderingMode: WidgetRenderingMode 27 28 var body: some View { 29 - VStack(alignment: .leading, spacing: 8) { // Reduced spacing from 16 to 8 30 - // War Memorial Section 31 - HStack(spacing: 8) { 32 - CircularProgressView( 33 - percentage: calculatePercentage(occupancy: entry.warMemorialOccupancy, maxCapacity: entry.maxWarMemorialCapacity), 34 - size: 40, 35 - lineWidth: 6, 36 - fontScale: 0.30, 37 - widgetRenderingMode: widgetRenderingMode, 38 - totalSegments: 10, 39 - isEmpty: entry.warMemorialOccupancy == 0, 40 - showPercentageSymbol: false // Disable "%" 41 - ) 42 - VStack(alignment: .leading, spacing: 4) { // Reduced spacing from 4 to 2 43 - Text("War Memorial") 44 - .font(.system(size: 11, weight: .bold)) 45 - .widgetAccentable() 46 - .lineLimit(1) // Ensure single line 47 - HStack(spacing: 0) { 48 - Text("\(entry.warMemorialOccupancy)") 49 - .font(.system(size: 13)) 50 - .widgetAccentable() 51 - .layoutPriority(1) // Ensures this text gets priority 52 - Text(" / \(entry.maxWarMemorialCapacity)") 53 - .font(.system(size: 13)) 54 - .foregroundColor(.secondary) 55 - .lineLimit(1) // Ensure single line 56 - } 57 - } 58 - } 59 60 - // Divider with default appearance 61 Divider() 62 - .padding(.vertical, 10) // Reduced vertical padding from 4 to 2 63 64 - // McComas Section 65 - HStack(spacing: 8) { 66 - CircularProgressView( 67 - percentage: calculatePercentage(occupancy: entry.mcComasOccupancy, maxCapacity: entry.maxMcComasCapacity), 68 - size: 40, 69 - lineWidth: 6, 70 - fontScale: 0.30, 71 - widgetRenderingMode: widgetRenderingMode, 72 - totalSegments: 10, 73 - isEmpty: entry.mcComasOccupancy == 0, 74 - showPercentageSymbol: false // Disable "%" 75 - ) 76 - VStack(alignment: .leading, spacing: 4) { // Reduced spacing from 4 to 2 77 - Text("McComas") 78 - .font(.system(size: 11, weight: .bold)) 79 - .widgetAccentable() 80 - .lineLimit(1) // Ensure single line 81 - HStack(spacing: 0) { 82 - Text("\(entry.mcComasOccupancy)") 83 - .font(.system(size: 13)) 84 - .widgetAccentable() 85 - .layoutPriority(1) // Ensures this text gets priority 86 - Text(" / \(entry.maxMcComasCapacity)") 87 - .font(.system(size: 13)) 88 - .foregroundColor(.secondary) 89 - .lineLimit(1) // Ensure single line 90 - } 91 - } 92 - } 93 } 94 - .padding(.horizontal, 0) // Removed horizontal padding 95 - .padding(.vertical, 0) 96 - .containerBackground(Color(.systemBackground), for: .widget) 97 } 98 99 - private func calculatePercentage(occupancy: Int, maxCapacity: Int) -> Double { 100 guard maxCapacity > 0 else { return 0 } 101 return (Double(occupancy) / Double(maxCapacity)) * 100 102 } 103 } 104 105 - // MARK: - Medium Widget 106 struct MediumWidgetView: View { 107 let entry: UnifiedGymTrackerEntry 108 let widgetRenderingMode: WidgetRenderingMode 109 110 var body: some View { 111 - HStack { 112 - Spacer() 113 - // War Memorial Section 114 - VStack(spacing: 4) { 115 - CircularProgressView( 116 - percentage: calculatePercentage(occupancy: entry.warMemorialOccupancy, maxCapacity: entry.maxWarMemorialCapacity), 117 - size: 70, 118 - lineWidth: 8, 119 - fontScale: 0.25, 120 - widgetRenderingMode: widgetRenderingMode, 121 - totalSegments: 20, 122 - isEmpty: entry.warMemorialOccupancy == 0, 123 - showPercentageSymbol: true 124 - ) 125 - Text("War Memorial") 126 - .font(.system(size: 14, weight: .bold)) 127 - .padding(.top, 8) 128 - HStack(spacing: 0) { 129 - Text("\(entry.warMemorialOccupancy)") 130 - .font(.system(size: 14)) 131 - Text(" / \(entry.maxWarMemorialCapacity)") 132 - .font(.system(size: 14)) 133 - .foregroundColor(.secondary) 134 - } 135 - } 136 - Spacer() 137 - Divider().frame(height: 90).padding(.horizontal) 138 - Spacer() 139 - // McComas Section 140 - VStack(spacing: 4) { 141 - CircularProgressView( 142 - percentage: calculatePercentage(occupancy: entry.mcComasOccupancy, maxCapacity: entry.maxMcComasCapacity), 143 - size: 70, 144 - lineWidth: 8, 145 - fontScale: 0.25, 146 - widgetRenderingMode: widgetRenderingMode, 147 - totalSegments: 20, 148 - isEmpty: entry.mcComasOccupancy == 0, 149 - showPercentageSymbol: true 150 - ) 151 - Text("McComas") 152 - .font(.system(size: 14, weight: .bold)) 153 - .padding(.top, 8) 154 - HStack(spacing: 0) { 155 - Text("\(entry.mcComasOccupancy)") 156 - .font(.system(size: 14)) 157 - Text(" / \(entry.maxMcComasCapacity)") 158 - .font(.system(size: 14)) 159 - .foregroundColor(.secondary) 160 - } 161 - } 162 - Spacer() 163 } 164 - .padding(.vertical, 12) 165 - .containerBackground(Color(.systemBackground), for: .widget) 166 - } 167 - 168 - private func calculatePercentage(occupancy: Int, maxCapacity: Int) -> Double { 169 - guard maxCapacity > 0 else { return 0 } 170 - return (Double(occupancy) / Double(maxCapacity)) * 100 171 } 172 } 173 ··· 189 190 var body: some View { 191 ZStack { 192 - // Background segments 193 ForEach(0..<totalSegments, id: \.self) { index in 194 SegmentShape( 195 startAngle: segmentStartAngle(for: index), ··· 197 lineWidth: lineWidth 198 ) 199 .stroke(lineWidth: lineWidth) 200 - .foregroundColor(isEmpty ? Color.gray.opacity(0.2) : segmentColor(index).opacity(0.2)) 201 } 202 203 // Foreground segments ··· 274 .configurationDisplayName("Gym Tracker") 275 .description("Displays live occupancy for campus gyms") 276 .supportedFamilies([.systemSmall, .systemMedium]) 277 } 278 } 279 ··· 286 date: Date(), 287 mcComasOccupancy: 450, 288 warMemorialOccupancy: 900, 289 maxMcComasCapacity: Constants.mcComasMaxCapacity, 290 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 291 ) 292 ) 293 .previewContext(WidgetPreviewContext(family: .systemSmall)) ··· 297 date: Date(), 298 mcComasOccupancy: 0, // Empty state 299 warMemorialOccupancy: 0, // Empty state 300 maxMcComasCapacity: Constants.mcComasMaxCapacity, 301 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 302 ) 303 ) 304 .previewContext(WidgetPreviewContext(family: .systemMedium))
··· 9 @Environment(\.widgetRenderingMode) var widgetRenderingMode 10 11 var body: some View { 12 + Group { 13 + switch widgetFamily { 14 + case .systemSmall: 15 + SmallWidgetView(entry: entry, widgetRenderingMode: widgetRenderingMode) 16 + case .systemMedium: 17 + MediumWidgetView(entry: entry, widgetRenderingMode: widgetRenderingMode) 18 + default: 19 + EmptyView() 20 + } 21 + } 22 + .containerBackground(for: .widget) { 23 + Color(.systemBackground) 24 + } 25 + } 26 + } 27 + 28 + // MARK: - Occupancy Row (app-consistent: circular segmented progress + n/max like OccupancyCard) 29 + struct OccupancyRowView: View { 30 + let title: String 31 + let occupancy: Int 32 + let maxCapacity: Int 33 + let totalSegments: Int 34 + let circleSize: CGFloat 35 + let lineWidth: CGFloat 36 + let fontScale: CGFloat 37 + let titleFont: CGFloat 38 + let countFont: CGFloat 39 + let showPercentageInCircle: Bool 40 + let widgetRenderingMode: WidgetRenderingMode 41 + 42 + private var percentage: Double { 43 + guard maxCapacity > 0 else { return 0 } 44 + return (Double(occupancy) / Double(maxCapacity)) * 100 45 + } 46 + 47 + var body: some View { 48 + HStack(alignment: .center, spacing: 10) { 49 + CircularProgressView( 50 + percentage: percentage, 51 + size: circleSize, 52 + lineWidth: lineWidth, 53 + fontScale: fontScale, 54 + widgetRenderingMode: widgetRenderingMode, 55 + totalSegments: totalSegments, 56 + isEmpty: occupancy == 0, 57 + showPercentageSymbol: showPercentageInCircle 58 + ) 59 + 60 + VStack(alignment: .leading, spacing: 2) { 61 + Text(title) 62 + .font(.system(size: titleFont, weight: .semibold)) 63 + .foregroundColor(.primary) 64 + .lineLimit(1) 65 + // Same size for occupancy and max; keep occupancy primary, max secondary 66 + (Text("\(occupancy.abbreviatedCount)") 67 + .font(.system(size: countFont)) 68 + .foregroundColor(.primary) 69 + + Text(" / \(maxCapacity.abbreviatedCount)") 70 + .font(.system(size: countFont)) 71 + .foregroundColor(.secondary)) 72 + .lineLimit(1) 73 + } 74 + .frame(maxWidth: .infinity, alignment: .leading) 75 } 76 } 77 } 78 79 + // MARK: - Small Widget (stacked rows, app-consistent; circular segmented progress) 80 struct SmallWidgetView: View { 81 let entry: UnifiedGymTrackerEntry 82 let widgetRenderingMode: WidgetRenderingMode 83 84 var body: some View { 85 + VStack(alignment: .leading, spacing: 8) { 86 + OccupancyRowView( 87 + title: "War Memorial", 88 + occupancy: entry.warMemorialOccupancy, 89 + maxCapacity: entry.maxWarMemorialCapacity, 90 + totalSegments: 20, 91 + circleSize: 36, 92 + lineWidth: 4, 93 + fontScale: 0.32, 94 + titleFont: 11, 95 + countFont: 12, 96 + showPercentageInCircle: false, 97 + widgetRenderingMode: widgetRenderingMode 98 + ) 99 100 Divider() 101 102 + OccupancyRowView( 103 + title: "McComas", 104 + occupancy: entry.mcComasOccupancy, 105 + maxCapacity: entry.maxMcComasCapacity, 106 + totalSegments: 20, 107 + circleSize: 36, 108 + lineWidth: 4, 109 + fontScale: 0.32, 110 + titleFont: 11, 111 + countFont: 12, 112 + showPercentageInCircle: false, 113 + widgetRenderingMode: widgetRenderingMode 114 + ) 115 + 116 + Divider() 117 + 118 + OccupancyRowView( 119 + title: "Bouldering Wall", 120 + occupancy: entry.boulderingWallOccupancy, 121 + maxCapacity: entry.maxBoulderingWallCapacity, 122 + totalSegments: 8, 123 + circleSize: 36, 124 + lineWidth: 4, 125 + fontScale: 0.32, 126 + titleFont: 11, 127 + countFont: 12, 128 + showPercentageInCircle: false, 129 + widgetRenderingMode: widgetRenderingMode 130 + ) 131 } 132 + .padding(12) 133 } 134 + } 135 136 + // MARK: - Medium Widget Column (large circle on top, info beneath) 137 + private struct MediumWidgetColumnView: View { 138 + let title: String 139 + let occupancy: Int 140 + let maxCapacity: Int 141 + let totalSegments: Int 142 + let widgetRenderingMode: WidgetRenderingMode 143 + 144 + private var percentage: Double { 145 guard maxCapacity > 0 else { return 0 } 146 return (Double(occupancy) / Double(maxCapacity)) * 100 147 } 148 + 149 + var body: some View { 150 + VStack(spacing: 10) { 151 + CircularProgressView( 152 + percentage: percentage, 153 + size: 76, 154 + lineWidth: 8, 155 + fontScale: 0.26, 156 + widgetRenderingMode: widgetRenderingMode, 157 + totalSegments: totalSegments, 158 + isEmpty: occupancy == 0, 159 + showPercentageSymbol: true 160 + ) 161 + .padding(.bottom, 5) 162 + 163 + VStack(spacing: 2) { 164 + Text(title) 165 + .font(.system(size: 12, weight: .semibold)) 166 + .foregroundColor(.primary) 167 + .lineLimit(1) 168 + .minimumScaleFactor(0.75) 169 + 170 + (Text("\(occupancy.abbreviatedCount)") 171 + .font(.system(size: 13)) 172 + .foregroundColor(.primary) 173 + + Text(" / \(maxCapacity.abbreviatedCount)") 174 + .font(.system(size: 13)) 175 + .foregroundColor(.secondary)) 176 + .lineLimit(1) 177 + } 178 + } 179 + .frame(maxWidth: .infinity) 180 + } 181 } 182 183 + // MARK: - Medium Widget (3 columns: large circle on top, info beneath each) 184 struct MediumWidgetView: View { 185 let entry: UnifiedGymTrackerEntry 186 let widgetRenderingMode: WidgetRenderingMode 187 188 var body: some View { 189 + HStack(alignment: .top, spacing: 8) { 190 + MediumWidgetColumnView( 191 + title: "War Memorial", 192 + occupancy: entry.warMemorialOccupancy, 193 + maxCapacity: entry.maxWarMemorialCapacity, 194 + totalSegments: 20, 195 + widgetRenderingMode: widgetRenderingMode 196 + ) 197 + MediumWidgetColumnView( 198 + title: "McComas", 199 + occupancy: entry.mcComasOccupancy, 200 + maxCapacity: entry.maxMcComasCapacity, 201 + totalSegments: 20, 202 + widgetRenderingMode: widgetRenderingMode 203 + ) 204 + MediumWidgetColumnView( 205 + title: "Bouldering Wall", 206 + occupancy: entry.boulderingWallOccupancy, 207 + maxCapacity: entry.maxBoulderingWallCapacity, 208 + totalSegments: 8, 209 + widgetRenderingMode: widgetRenderingMode 210 + ) 211 } 212 + .padding(12) 213 } 214 } 215 ··· 231 232 var body: some View { 233 ZStack { 234 + // Background segments (tinted track: segment color 0.28, empty 0.22) 235 ForEach(0..<totalSegments, id: \.self) { index in 236 SegmentShape( 237 startAngle: segmentStartAngle(for: index), ··· 239 lineWidth: lineWidth 240 ) 241 .stroke(lineWidth: lineWidth) 242 + .foregroundColor(isEmpty ? Color("WidgetCustomGreen").opacity(0.22) : segmentColor(index).opacity(0.28)) 243 } 244 245 // Foreground segments ··· 316 .configurationDisplayName("Gym Tracker") 317 .description("Displays live occupancy for campus gyms") 318 .supportedFamilies([.systemSmall, .systemMedium]) 319 + .contentMarginsDisabled() // Avoid iOS 17+ system margins that can cause background/edge glitches on open/close 320 } 321 } 322 ··· 329 date: Date(), 330 mcComasOccupancy: 450, 331 warMemorialOccupancy: 900, 332 + boulderingWallOccupancy: 5, 333 maxMcComasCapacity: Constants.mcComasMaxCapacity, 334 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 335 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 336 ) 337 ) 338 .previewContext(WidgetPreviewContext(family: .systemSmall)) ··· 342 date: Date(), 343 mcComasOccupancy: 0, // Empty state 344 warMemorialOccupancy: 0, // Empty state 345 + boulderingWallOccupancy: 0, // Empty state 346 maxMcComasCapacity: Constants.mcComasMaxCapacity, 347 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 348 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 349 ) 350 ) 351 .previewContext(WidgetPreviewContext(family: .systemMedium))
+1
GymTrackerWidget/GymTrackerWidgetBundle.swift
··· 13 // Lock Screen Widgets 14 McComasCircularWidget() // Lock screen circular widget for McComas 15 WarMemorialCircularWidget() // Lock screen circular widget for War Memorial 16 GymTrackerRectangularWidget() // Lock screen rectangular widget for occupancy details 17 } 18 }
··· 13 // Lock Screen Widgets 14 McComasCircularWidget() // Lock screen circular widget for McComas 15 WarMemorialCircularWidget() // Lock screen circular widget for War Memorial 16 + BoulderingWallCircularWidget() // Lock screen circular widget for Bouldering Wall 17 GymTrackerRectangularWidget() // Lock screen rectangular widget for occupancy details 18 } 19 }