my swift app for VT gyms gymtracker.jackhannon.net
at main 228 lines 9.1 kB view raw
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}