NYTBrowseView.swift (8135B)
1 import SwiftUI 2 3 struct NYTBrowseView: View { 4 let onSelected: (String) -> Void 5 6 @Environment(\.nytPuzzleFetcher) private var fetcher 7 @Environment(NYTAuthService.self) private var nytAuth 8 @State private var displayedMonth: Date = NYTBrowseView.startOfCurrentMonth() 9 @State private var selectedDate: Date? 10 @State private var isLoading = false 11 @State private var errorMessage: String? 12 @State private var sessionExpired = false 13 14 private static let nytTimeZone = TimeZone(identifier: "America/New_York")! 15 16 private static var nytCalendar: Calendar { 17 var cal = Calendar(identifier: .gregorian) 18 cal.timeZone = nytTimeZone 19 return cal 20 } 21 22 private static let minDate: Date = { 23 var comps = DateComponents() 24 comps.year = 2001 25 comps.month = 1 26 comps.day = 1 27 return nytCalendar.date(from: comps)! 28 }() 29 30 private static func startOfCurrentMonth() -> Date { 31 let cal = nytCalendar 32 let comps = cal.dateComponents([.year, .month], from: Date()) 33 return cal.date(from: comps) ?? Date() 34 } 35 36 private let columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) 37 private let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"] 38 39 var body: some View { 40 VStack(spacing: 16) { 41 monthHeader 42 weekdayHeader 43 dayGrid 44 Spacer() 45 confirmButton 46 } 47 .padding() 48 .disabled(isLoading) 49 .overlay { 50 if isLoading { 51 ProgressView("Fetching puzzle…") 52 .padding() 53 .background(.regularMaterial, in: .rect(cornerRadius: 12)) 54 } 55 } 56 .alert( 57 "Couldn't Fetch Puzzle", 58 isPresented: .init( 59 get: { errorMessage != nil }, 60 set: { if !$0 { errorMessage = nil } } 61 ), 62 presenting: errorMessage 63 ) { _ in 64 Button("OK", role: .cancel) {} 65 } message: { message in 66 Text(message) 67 } 68 .alert("NYT Session Expired", isPresented: $sessionExpired) { 69 Button("OK", role: .cancel) { 70 nytAuth.signOut() 71 } 72 } message: { 73 Text("Your NYT session has expired. Sign in again from Settings to resume fetching puzzles.") 74 } 75 } 76 77 private var monthHeader: some View { 78 HStack { 79 Button { 80 shiftMonth(by: -1) 81 } label: { 82 Image(systemName: "chevron.left") 83 .font(.title3) 84 } 85 .disabled(!canGoBack) 86 87 Spacer() 88 89 Text(monthTitle) 90 .font(.headline) 91 92 Spacer() 93 94 Button { 95 shiftMonth(by: 1) 96 } label: { 97 Image(systemName: "chevron.right") 98 .font(.title3) 99 } 100 .disabled(!canGoForward) 101 } 102 } 103 104 private var weekdayHeader: some View { 105 HStack(spacing: 0) { 106 ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in 107 Text(symbol) 108 .font(.caption) 109 .foregroundStyle(.secondary) 110 .frame(maxWidth: .infinity) 111 } 112 } 113 } 114 115 private var dayGrid: some View { 116 LazyVGrid(columns: columns, spacing: 4) { 117 ForEach(Array(gridCells.enumerated()), id: \.offset) { _, cell in 118 if let date = cell { 119 let cal = Self.nytCalendar 120 let dayNumber = cal.component(.day, from: date) 121 CalendarDayCell( 122 dayNumber: dayNumber, 123 isEnabled: isEnabled(date), 124 isToday: cal.isDateInToday(date), 125 isSelected: isSelected(date), 126 onTap: { selectedDate = date } 127 ) 128 } else { 129 Color.clear.frame(minHeight: 44) 130 } 131 } 132 } 133 } 134 135 private var confirmButton: some View { 136 Button { 137 if let selectedDate { 138 fetch(selectedDate) 139 } 140 } label: { 141 Text(confirmButtonTitle) 142 .font(.headline) 143 .frame(maxWidth: .infinity) 144 } 145 .buttonStyle(.borderedProminent) 146 .controlSize(.large) 147 .disabled(selectedDate == nil) 148 } 149 150 private var confirmButtonTitle: String { 151 guard let selectedDate else { return "Select a Date" } 152 let style = Date.FormatStyle( 153 date: .complete, 154 time: .omitted, 155 locale: .current, 156 calendar: Self.nytCalendar, 157 timeZone: Self.nytTimeZone 158 ) 159 return "Start \(selectedDate.formatted(style))" 160 } 161 162 private func isSelected(_ date: Date) -> Bool { 163 guard let selectedDate else { return false } 164 return Self.nytCalendar.isDate(date, inSameDayAs: selectedDate) 165 } 166 167 // MARK: - Month navigation 168 169 private func shiftMonth(by delta: Int) { 170 let cal = Self.nytCalendar 171 if let next = cal.date(byAdding: .month, value: delta, to: displayedMonth) { 172 displayedMonth = next 173 } 174 } 175 176 private var monthTitle: String { 177 let formatter = DateFormatter() 178 formatter.calendar = Self.nytCalendar 179 formatter.timeZone = Self.nytTimeZone 180 formatter.dateFormat = "MMMM yyyy" 181 return formatter.string(from: displayedMonth) 182 } 183 184 private var canGoBack: Bool { 185 let cal = Self.nytCalendar 186 let minMonth = cal.dateComponents([.year, .month], from: Self.minDate) 187 let current = cal.dateComponents([.year, .month], from: displayedMonth) 188 guard let cy = current.year, let cm = current.month, 189 let my = minMonth.year, let mm = minMonth.month else { return false } 190 return (cy, cm) > (my, mm) 191 } 192 193 private var canGoForward: Bool { 194 let cal = Self.nytCalendar 195 let today = cal.dateComponents([.year, .month], from: Date()) 196 let current = cal.dateComponents([.year, .month], from: displayedMonth) 197 guard let cy = current.year, let cm = current.month, 198 let ty = today.year, let tm = today.month else { return false } 199 return (cy, cm) < (ty, tm) 200 } 201 202 // MARK: - Grid 203 204 private var gridCells: [Date?] { 205 var cells: [Date?] = [] 206 let cal = Self.nytCalendar 207 let monthComps = cal.dateComponents([.year, .month], from: displayedMonth) 208 guard let firstOfMonth = cal.date(from: monthComps), 209 let range = cal.range(of: .day, in: .month, for: firstOfMonth) else { 210 return cells 211 } 212 let firstWeekday = cal.component(.weekday, from: firstOfMonth) 213 let leadingBlanks = firstWeekday - 1 214 for _ in 0..<leadingBlanks { cells.append(nil) } 215 for day in range { 216 cells.append(cal.date(byAdding: .day, value: day - 1, to: firstOfMonth)) 217 } 218 while cells.count % 7 != 0 { cells.append(nil) } 219 return cells 220 } 221 222 private func isEnabled(_ date: Date) -> Bool { 223 let cal = Self.nytCalendar 224 let dayStart = cal.startOfDay(for: date) 225 let minDayStart = cal.startOfDay(for: Self.minDate) 226 let todayStart = cal.startOfDay(for: Date()) 227 return dayStart >= minDayStart && dayStart <= todayStart 228 } 229 230 // MARK: - Fetch 231 232 private func fetch(_ date: Date) { 233 guard let fetcher else { 234 errorMessage = "Puzzle fetcher unavailable." 235 return 236 } 237 isLoading = true 238 Task { @MainActor in 239 defer { isLoading = false } 240 do { 241 let source = try await fetcher.fetchPuzzle(for: date) 242 onSelected(source) 243 } catch NYTFetchError.unauthorized { 244 sessionExpired = true 245 } catch { 246 errorMessage = error.localizedDescription 247 } 248 } 249 } 250 }