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 15 VStack(alignment: .leading, spacing: 8) { 16 16 HStack { 17 17 // Display current occupancy and max capacity 18 - Text("\(occupancy)") 18 + Text("\(occupancy.abbreviatedCount)") 19 19 .font(.subheadline) 20 20 .bold() 21 21 + 22 - Text(" / \(maxCapacity)") 22 + Text(" / \(maxCapacity.abbreviatedCount)") 23 23 .font(.subheadline) 24 24 .foregroundColor(.secondary) 25 25
+4 -5
Gym Tracker (RC)/SegmentedProgressBar.swift
··· 78 78 } 79 79 } 80 80 81 - // Determines the background color for a segment based on its index and empty state 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. 82 83 private func backgroundColor(for index: Int) -> Color { 83 84 if isEmpty { 84 - // Greyed-out background when empty 85 - return Color.gray.opacity(0.2) 85 + return Color("CustomGreen").opacity(0.22) 86 86 } else { 87 87 let fraction = CGFloat(index + 1) / CGFloat(totalSegments) 88 - let color = occupancyColor(for: fraction) 89 - return color.opacity(0.2) 88 + return occupancyColor(for: fraction).opacity(0.28) 90 89 } 91 90 } 92 91
+9
Gym Tracker (RC)/Services/Constants.swift
··· 3 3 4 4 import Foundation 5 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 + 6 15 struct Constants { 7 16 // Facility IDs 8 17 static let mcComasFacilityId = "da73849e-434d-415f-975a-4f9e799b9c39"
+5 -4
Gym Tracker (RC)/Services/GymOccupancyFetcher.swift
··· 10 10 return URLSession(configuration: c) 11 11 }() 12 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?) { 13 + /// Widget: McComas, War Memorial, and Bouldering Wall. Returns occupancy per facility. 14 + static func fetchForWidget() async -> (mcComas: Int?, warMemorial: Int?, boulderingWall: Int?) { 15 15 async let mc = fetchOne(facilityId: Constants.mcComasFacilityId) 16 16 async let wm = fetchOne(facilityId: Constants.warMemorialFacilityId) 17 - let (m, w) = await (mc, wm) 18 - return (m?.occupancy, w?.occupancy) 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) 19 20 } 20 21 21 22 /// Main app: all three facilities. Returns (occupancy, remaining) per facility.
+17 -6
Gym Tracker (RC)/Services/UnifiedGymTrackerProvider.swift
··· 5 5 let date: Date 6 6 let mcComasOccupancy: Int 7 7 let warMemorialOccupancy: Int 8 + let boulderingWallOccupancy: Int 8 9 let maxMcComasCapacity: Int 9 10 let maxWarMemorialCapacity: Int 11 + let maxBoulderingWallCapacity: Int 10 12 } 11 13 12 14 struct UnifiedGymTrackerProvider: TimelineProvider { ··· 16 18 date: Date(), 17 19 mcComasOccupancy: 300, 18 20 warMemorialOccupancy: 600, 21 + boulderingWallOccupancy: 4, 19 22 maxMcComasCapacity: Constants.mcComasMaxCapacity, 20 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 23 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 24 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 21 25 ) 22 26 } 23 27 ··· 27 31 28 32 func getTimeline(in context: Context, completion: @escaping (Timeline<UnifiedGymTrackerEntry>) -> Void) { 29 33 Task { 30 - let (mc, wm) = await GymOccupancyFetcher.fetchForWidget() 34 + let (mc, wm, bw) = await GymOccupancyFetcher.fetchForWidget() 31 35 let shared = UserDefaults(suiteName: Constants.appGroupID) 32 36 let mcFinal = mc ?? shared?.integer(forKey: "mcComasOccupancy") ?? 0 33 37 let wmFinal = wm ?? shared?.integer(forKey: "warMemorialOccupancy") ?? 0 38 + let bwFinal = bw ?? shared?.integer(forKey: "boulderingWallOccupancy") ?? 0 34 39 35 40 if mc != nil { shared?.set(mc!, forKey: "mcComasOccupancy") } 36 41 if wm != nil { shared?.set(wm!, forKey: "warMemorialOccupancy") } 37 - if mc != nil || wm != nil { shared?.set(Date(), forKey: "lastFetchDate") } 42 + if bw != nil { shared?.set(bw!, forKey: "boulderingWallOccupancy") } 43 + if mc != nil || wm != nil || bw != nil { shared?.set(Date(), forKey: "lastFetchDate") } 38 44 39 45 let entry = UnifiedGymTrackerEntry( 40 46 date: Date(), 41 47 mcComasOccupancy: mcFinal, 42 48 warMemorialOccupancy: wmFinal, 49 + boulderingWallOccupancy: bwFinal, 43 50 maxMcComasCapacity: Constants.mcComasMaxCapacity, 44 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 51 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 52 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 45 53 ) 46 54 let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(15 * 60) 47 55 completion(Timeline(entries: [entry], policy: .after(next))) ··· 52 60 let sharedDefaults = UserDefaults(suiteName: Constants.appGroupID) 53 61 let mcOccupancy = sharedDefaults?.integer(forKey: "mcComasOccupancy") ?? 0 54 62 let wmOccupancy = sharedDefaults?.integer(forKey: "warMemorialOccupancy") ?? 0 55 - 63 + let bwOccupancy = sharedDefaults?.integer(forKey: "boulderingWallOccupancy") ?? 0 64 + 56 65 return UnifiedGymTrackerEntry( 57 66 date: Date(), 58 67 mcComasOccupancy: mcOccupancy, 59 68 warMemorialOccupancy: wmOccupancy, 69 + boulderingWallOccupancy: bwOccupancy, 60 70 maxMcComasCapacity: Constants.mcComasMaxCapacity, 61 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 71 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 72 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 62 73 ) 63 74 } 64 75 }
+4 -2
GymTrackerComplications/AppIntent.swift
··· 22 22 enum GymOption: String, AppEnum { 23 23 case mcComas = "McComas Hall" 24 24 case warMemorial = "War Memorial Hall" 25 - 25 + case boulderingWall = "Bouldering Wall" 26 + 26 27 static var typeDisplayRepresentation: TypeDisplayRepresentation { 27 28 "Gym Options" 28 29 } ··· 30 31 static var caseDisplayRepresentations: [GymOption: DisplayRepresentation] { 31 32 [ 32 33 .mcComas: "McComas Hall", 33 - .warMemorial: "War Memorial Hall" 34 + .warMemorial: "War Memorial Hall", 35 + .boulderingWall: "Bouldering Wall" 34 36 ] 35 37 } 36 38 }
+2 -2
GymTrackerWatch Watch App/WatchCircularProgressView.swift
··· 19 19 20 20 var body: some View { 21 21 ZStack { 22 - // Background segments 22 + // Background segments (tinted track: segment color 0.28, empty 0.22) 23 23 ForEach(0..<totalSegments, id: \.self) { index in 24 24 WatchSegmentShape( 25 25 startAngle: segmentStartAngle(for: index), ··· 27 27 lineWidth: lineWidth 28 28 ) 29 29 .stroke(lineWidth: lineWidth) 30 - .foregroundColor(isEmpty ? Color.gray.opacity(0.2) : segmentColor(index).opacity(0.2)) 30 + .foregroundColor(isEmpty ? Color("WatchCustomGreen").opacity(0.22) : segmentColor(index).opacity(0.28)) 31 31 } 32 32 33 33 // Foreground segments
+1 -1
GymTrackerWatch Watch App/WatchGymCardView.swift
··· 39 39 // Occupancy numbers 40 40 VStack(spacing: 4) { 41 41 if networkMonitor.isConnected { 42 - Text("\(occupancy) / \(maxCapacity)") 42 + Text("\(occupancy.abbreviatedCount) / \(maxCapacity.abbreviatedCount)") 43 43 .font(.caption) 44 44 .foregroundColor(.secondary) 45 45 .multilineTextAlignment(.center)
+6 -3
GymTrackerWatch Watch App/WatchSegmentedProgressBar.swift
··· 25 25 } 26 26 } 27 27 28 - // Determine the color for each segment based on occupancy 28 + // Determine the color for each segment based on occupancy (tinted track: 0.28, empty 0.22) 29 29 private func colorForSegment(index: Int) -> Color { 30 30 let segmentThreshold = CGFloat(index + 1) / CGFloat(totalSegments) 31 + let color = progressColor(for: segmentThreshold) 31 32 if occupancyPercentage >= segmentThreshold { 32 - return progressColor(for: segmentThreshold) 33 + return color 33 34 } else { 34 - return Color.gray.opacity(0.3) 35 + return occupancyPercentage == 0 36 + ? Color("WatchCustomGreen").opacity(0.22) 37 + : color.opacity(0.28) 35 38 } 36 39 } 37 40
+162 -214
GymTrackerWidget/GymTrackerLockscreenWidget.swift
··· 1 - // GymLockScreenWidget.swift 1 + // GymTrackerLockscreenWidget.swift 2 + // Lock screen accessory widgets: circular (per gym) and rectangular (all three). 3 + // Circular uses the branded segmented progress ring. 2 4 3 5 import WidgetKit 4 6 import SwiftUI 5 7 6 - // MARK: - McComas Circular Widget View 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) 7 102 struct McComasCircularWidgetView: View { 8 103 let entry: UnifiedGymTrackerEntry 9 104 10 105 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) 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") 23 110 } 24 111 } 25 112 26 - // MARK: - War Memorial Circular Widget View 27 113 struct WarMemorialCircularWidgetView: View { 28 114 let entry: UnifiedGymTrackerEntry 29 115 30 116 var body: some View { 31 - let percentage = (Double(entry.warMemorialOccupancy) / Double(entry.maxWarMemorialCapacity)) * 100.0 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 + } 32 123 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) 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") 43 132 } 44 133 } 45 134 46 - // MARK: - Rectangular LockScreen Widget View 135 + // MARK: - Rectangular Lock Screen Widget (all three gyms) 47 136 struct RectangularLockScreenWidgetView: View { 48 137 let entry: UnifiedGymTrackerEntry 49 138 50 139 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 - } 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) 113 144 } 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) 145 + .frame(maxWidth: .infinity, alignment: .leading) 146 + .padding(.horizontal, 4) 147 + .padding(.vertical, 6) 119 148 } 120 149 121 - private func calculatePercentage(occupancy: Int, maxCapacity: Int) -> Double { 122 - guard maxCapacity > 0 else { return 0 } 123 - return (Double(occupancy) / Double(maxCapacity)) * 100 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 + } 124 165 } 125 166 } 126 167 127 - // MARK: - McComasCircularWidget 168 + // MARK: - Widgets 169 + 128 170 struct McComasCircularWidget: Widget { 129 171 let kind: String = "McComasCircularWidget" 130 172 ··· 132 174 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 133 175 McComasCircularWidgetView(entry: entry) 134 176 } 135 - .configurationDisplayName("McComas Gym") 136 - .description("Shows occupancy for McComas Gym.") 177 + .configurationDisplayName("McComas") 178 + .description("McComas gym occupancy") 137 179 .supportedFamilies([.accessoryCircular]) 180 + .contentMarginsDisabled() 138 181 } 139 182 } 140 183 141 - // MARK: - WarMemorialCircularWidget 142 184 struct WarMemorialCircularWidget: Widget { 143 185 let kind: String = "WarMemorialCircularWidget" 144 186 ··· 146 188 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 147 189 WarMemorialCircularWidgetView(entry: entry) 148 190 } 149 - .configurationDisplayName("War Memorial Gym") 150 - .description("Shows occupancy for War Memorial Gym.") 191 + .configurationDisplayName("War Memorial") 192 + .description("War Memorial gym occupancy") 151 193 .supportedFamilies([.accessoryCircular]) 194 + .contentMarginsDisabled() 152 195 } 153 196 } 154 197 155 - // MARK: - GymTrackerRectangularWidget 156 - struct GymTrackerRectangularWidget: Widget { 157 - let kind: String = "GymTrackerRectangularWidget" 198 + struct BoulderingWallCircularWidget: Widget { 199 + let kind: String = "BoulderingWallCircularWidget" 158 200 159 201 var body: some WidgetConfiguration { 160 202 StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 161 - RectangularLockScreenWidgetView(entry: entry) 203 + BoulderingWallCircularWidgetView(entry: entry) 162 204 } 163 - .configurationDisplayName("Gym Tracker") 164 - .description("Shows gym occupancy for McComas and War Memorial.") 165 - .supportedFamilies([.accessoryRectangular]) 205 + .configurationDisplayName("Bouldering Wall") 206 + .description("Bouldering wall occupancy") 207 + .supportedFamilies([.accessoryCircular]) 208 + .contentMarginsDisabled() 166 209 } 167 210 } 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 211 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) 212 + struct GymTrackerRectangularWidget: Widget { 213 + let kind: String = "GymTrackerRectangularWidget" 243 214 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 215 + var body: some WidgetConfiguration { 216 + StaticConfiguration(kind: kind, provider: UnifiedGymTrackerProvider()) { entry in 217 + RectangularLockScreenWidgetView(entry: entry) 251 218 } 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 219 + .configurationDisplayName("VT Gyms") 220 + .description("War Memorial, McComas, and Bouldering occupancy") 221 + .supportedFamilies([.accessoryRectangular]) 222 + .contentMarginsDisabled() 275 223 } 276 224 }
+184 -137
GymTrackerWidget/GymTrackerWidget.swift
··· 9 9 @Environment(\.widgetRenderingMode) var widgetRenderingMode 10 10 11 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() 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) 19 75 } 20 76 } 21 77 } 22 78 23 - // MARK: - Small Widget 79 + // MARK: - Small Widget (stacked rows, app-consistent; circular segmented progress) 24 80 struct SmallWidgetView: View { 25 81 let entry: UnifiedGymTrackerEntry 26 82 let widgetRenderingMode: WidgetRenderingMode 27 83 28 84 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 - } 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 + ) 59 99 60 - // Divider with default appearance 61 100 Divider() 62 - .padding(.vertical, 10) // Reduced vertical padding from 4 to 2 63 101 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 - } 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 + ) 93 131 } 94 - .padding(.horizontal, 0) // Removed horizontal padding 95 - .padding(.vertical, 0) 96 - .containerBackground(Color(.systemBackground), for: .widget) 132 + .padding(12) 97 133 } 134 + } 98 135 99 - private func calculatePercentage(occupancy: Int, maxCapacity: Int) -> Double { 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 { 100 145 guard maxCapacity > 0 else { return 0 } 101 146 return (Double(occupancy) / Double(maxCapacity)) * 100 102 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 + } 103 181 } 104 182 105 - // MARK: - Medium Widget 183 + // MARK: - Medium Widget (3 columns: large circle on top, info beneath each) 106 184 struct MediumWidgetView: View { 107 185 let entry: UnifiedGymTrackerEntry 108 186 let widgetRenderingMode: WidgetRenderingMode 109 187 110 188 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() 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 + ) 163 211 } 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 212 + .padding(12) 171 213 } 172 214 } 173 215 ··· 189 231 190 232 var body: some View { 191 233 ZStack { 192 - // Background segments 234 + // Background segments (tinted track: segment color 0.28, empty 0.22) 193 235 ForEach(0..<totalSegments, id: \.self) { index in 194 236 SegmentShape( 195 237 startAngle: segmentStartAngle(for: index), ··· 197 239 lineWidth: lineWidth 198 240 ) 199 241 .stroke(lineWidth: lineWidth) 200 - .foregroundColor(isEmpty ? Color.gray.opacity(0.2) : segmentColor(index).opacity(0.2)) 242 + .foregroundColor(isEmpty ? Color("WidgetCustomGreen").opacity(0.22) : segmentColor(index).opacity(0.28)) 201 243 } 202 244 203 245 // Foreground segments ··· 274 316 .configurationDisplayName("Gym Tracker") 275 317 .description("Displays live occupancy for campus gyms") 276 318 .supportedFamilies([.systemSmall, .systemMedium]) 319 + .contentMarginsDisabled() // Avoid iOS 17+ system margins that can cause background/edge glitches on open/close 277 320 } 278 321 } 279 322 ··· 286 329 date: Date(), 287 330 mcComasOccupancy: 450, 288 331 warMemorialOccupancy: 900, 332 + boulderingWallOccupancy: 5, 289 333 maxMcComasCapacity: Constants.mcComasMaxCapacity, 290 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 334 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 335 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 291 336 ) 292 337 ) 293 338 .previewContext(WidgetPreviewContext(family: .systemSmall)) ··· 297 342 date: Date(), 298 343 mcComasOccupancy: 0, // Empty state 299 344 warMemorialOccupancy: 0, // Empty state 345 + boulderingWallOccupancy: 0, // Empty state 300 346 maxMcComasCapacity: Constants.mcComasMaxCapacity, 301 - maxWarMemorialCapacity: Constants.warMemorialMaxCapacity 347 + maxWarMemorialCapacity: Constants.warMemorialMaxCapacity, 348 + maxBoulderingWallCapacity: Constants.boulderingWallMaxCapacity 302 349 ) 303 350 ) 304 351 .previewContext(WidgetPreviewContext(family: .systemMedium))
+1
GymTrackerWidget/GymTrackerWidgetBundle.swift
··· 13 13 // Lock Screen Widgets 14 14 McComasCircularWidget() // Lock screen circular widget for McComas 15 15 WarMemorialCircularWidget() // Lock screen circular widget for War Memorial 16 + BoulderingWallCircularWidget() // Lock screen circular widget for Bouldering Wall 16 17 GymTrackerRectangularWidget() // Lock screen rectangular widget for occupancy details 17 18 } 18 19 }