NYTBrowseView.swift (17913B)
1 import SwiftUI 2 3 struct NYTBrowseView: View { 4 let onSelected: (String) -> Void 5 var excludedDates: Set<Date> = [] 6 7 @Environment(\.nytPuzzleFetcher) private var fetcher 8 @Environment(NYTAuthService.self) private var nytAuth 9 @State private var displayedMonth: Date = NYTBrowseView.startOfCurrentMonth() 10 @State private var selectedDate: Date? = NYTBrowseView.today() 11 @State private var isLoading = false 12 @State private var errorMessage: String? 13 @State private var sessionExpired = false 14 @State private var showingMonthPicker = false 15 @State private var pickerDate: Date = NYTBrowseView.startOfCurrentMonth() 16 @State private var randomWeekday: Int? = nil 17 @State private var randomYear: Int = NYTBrowseView.currentYear() 18 19 private static let nytTimeZone = TimeZone(identifier: "America/New_York")! 20 21 private static var nytCalendar: Calendar { 22 var cal = Calendar(identifier: .gregorian) 23 cal.timeZone = nytTimeZone 24 // Without an explicit locale the calendar uses a "fixed" locale whose 25 // weekdaySymbols are abbreviated ("Mon"); set the current locale so the 26 // weekday menu shows full names. 27 cal.locale = .current 28 return cal 29 } 30 31 private static let minDate: Date = { 32 var comps = DateComponents() 33 comps.year = 2001 34 comps.month = 1 35 comps.day = 1 36 return nytCalendar.date(from: comps)! 37 }() 38 39 private static func startOfCurrentMonth() -> Date { 40 let cal = nytCalendar 41 let comps = cal.dateComponents([.year, .month], from: Date()) 42 return cal.date(from: comps) ?? Date() 43 } 44 45 private static func today() -> Date { 46 nytCalendar.startOfDay(for: Date()) 47 } 48 49 private static func currentYear() -> Int { 50 nytCalendar.component(.year, from: Date()) 51 } 52 53 private static let selectableYears: [Int] = { 54 let start = nytCalendar.component(.year, from: minDate) 55 let end = nytCalendar.component(.year, from: Date()) 56 return Array(start...end) 57 }() 58 59 // Full weekday names (index 0 = Sunday) in the user's locale. Sourced from a 60 // DateFormatter rather than nytCalendar, whose unset "fixed" locale yields 61 // abbreviated symbols ("Mon"). 62 private static let fullWeekdaySymbols: [String] = { 63 let formatter = DateFormatter() 64 formatter.locale = .current 65 return formatter.weekdaySymbols 66 }() 67 68 private static func weekdayName(_ weekday: Int) -> String { 69 // weekday uses Gregorian numbering: 1 = Sunday … 7 = Saturday 70 fullWeekdaySymbols[(weekday - 1) % fullWeekdaySymbols.count] 71 } 72 73 private static let shortWeekdaySymbols: [String] = { 74 let formatter = DateFormatter() 75 formatter.locale = .current 76 return formatter.shortWeekdaySymbols 77 }() 78 79 private static func shortWeekdayName(_ weekday: Int) -> String { 80 // weekday uses Gregorian numbering: 1 = Sunday … 7 = Saturday 81 shortWeekdaySymbols[(weekday - 1) % shortWeekdaySymbols.count] 82 } 83 84 // Gregorian weekday numbers. The menu opens upward from the bottom-anchored 85 // button, so the first declared item lands nearest the button (visually at 86 // the bottom). Declaring Sunday → Monday here yields a top-to-bottom reading 87 // of Monday … Sunday, with "Any Day" (declared first) pinned to the bottom. 88 private static let weekdayMenuOrder = [1, 7, 6, 5, 4, 3, 2] 89 90 private let columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) 91 private let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"] 92 93 var body: some View { 94 Group { 95 switch nytAuth.sessionState { 96 case .unknown: 97 unavailableSessionView 98 case .signedIn: 99 browserView 100 case .signedOut: 101 signedOutView 102 } 103 } 104 .alert( 105 "Couldn't Fetch Puzzle", 106 isPresented: .init( 107 get: { errorMessage != nil }, 108 set: { if !$0 { errorMessage = nil } } 109 ), 110 presenting: errorMessage 111 ) { _ in 112 Button("OK", role: .cancel) {} 113 } message: { message in 114 Text(message) 115 } 116 .alert("NYT Session Expired", isPresented: $sessionExpired) { 117 Button("OK", role: .cancel) { 118 nytAuth.signOut() 119 } 120 } message: { 121 Text("Your NYT session has expired. Sign in again from Settings to resume fetching puzzles.") 122 } 123 } 124 125 private var browserView: some View { 126 VStack(spacing: 16) { 127 monthHeader 128 weekdayHeader 129 dayGrid 130 Spacer() 131 confirmButton 132 randomButton 133 } 134 .padding() 135 .disabled(isLoading) 136 .overlay { 137 if isLoading { 138 ProgressView("Fetching puzzle…") 139 .padding() 140 .background(.regularMaterial, in: .rect(cornerRadius: 12)) 141 } 142 } 143 } 144 145 private var unavailableSessionView: some View { 146 VStack(spacing: 16) { 147 Spacer() 148 Image(systemName: "lock.rotation") 149 .font(.largeTitle) 150 .foregroundStyle(.secondary) 151 Text(nytAuth.sessionStatusMessage ?? "Crossmate could not determine whether you are signed in to NYT.") 152 .font(.body) 153 .foregroundStyle(.secondary) 154 .multilineTextAlignment(.center) 155 Button { 156 nytAuth.loadStoredSession() 157 } label: { 158 Label("Try Again", systemImage: "arrow.clockwise") 159 .frame(maxWidth: .infinity) 160 } 161 .buttonStyle(.borderedProminent) 162 .controlSize(.large) 163 Spacer() 164 } 165 .padding() 166 } 167 168 private var signedOutView: some View { 169 VStack(spacing: 16) { 170 Spacer() 171 Image(systemName: "person.crop.circle.badge.exclamationmark") 172 .font(.largeTitle) 173 .foregroundStyle(.secondary) 174 Text("Sign in to NYT from Settings to fetch crossword puzzles.") 175 .font(.body) 176 .foregroundStyle(.secondary) 177 .multilineTextAlignment(.center) 178 Spacer() 179 } 180 .padding() 181 } 182 183 private var monthHeader: some View { 184 HStack { 185 Button { 186 shiftMonth(by: -1) 187 } label: { 188 Image(systemName: "chevron.left") 189 .font(.title3) 190 } 191 .disabled(!canGoBack) 192 193 Spacer() 194 195 Button { 196 pickerDate = selectedDate ?? displayedMonth 197 showingMonthPicker = true 198 } label: { 199 HStack(spacing: 4) { 200 Text(monthTitle) 201 Image(systemName: "chevron.down") 202 .font(.caption.weight(.semibold)) 203 } 204 .font(.headline) 205 } 206 .buttonStyle(.plain) 207 .popover(isPresented: $showingMonthPicker) { 208 dateWheelPopover 209 .presentationCompactAdaptation(.popover) 210 } 211 212 Spacer() 213 214 Button { 215 shiftMonth(by: 1) 216 } label: { 217 Image(systemName: "chevron.right") 218 .font(.title3) 219 } 220 .disabled(!canGoForward) 221 } 222 } 223 224 private var weekdayHeader: some View { 225 HStack(spacing: 0) { 226 ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in 227 Text(symbol) 228 .font(.caption) 229 .foregroundStyle(.secondary) 230 .frame(maxWidth: .infinity) 231 } 232 } 233 } 234 235 private var dayGrid: some View { 236 LazyVGrid(columns: columns, spacing: 4) { 237 ForEach(Array(gridCells.enumerated()), id: \.offset) { _, cell in 238 if let date = cell { 239 let cal = Self.nytCalendar 240 let dayNumber = cal.component(.day, from: date) 241 CalendarDayCell( 242 dayNumber: dayNumber, 243 isEnabled: isEnabled(date), 244 isToday: cal.isDateInToday(date), 245 isSelected: isSelected(date), 246 onTap: { selectedDate = date } 247 ) 248 } else { 249 Color.clear.frame(minHeight: 44) 250 } 251 } 252 } 253 } 254 255 private var confirmButton: some View { 256 Button { 257 if let selectedDate { 258 fetch(selectedDate) 259 } 260 } label: { 261 Text(confirmButtonTitle) 262 .font(.headline) 263 .frame(maxWidth: .infinity) 264 } 265 .buttonStyle(.borderedProminent) 266 .controlSize(.large) 267 .disabled(selectedDate == nil) 268 } 269 270 private var confirmButtonTitle: String { 271 guard let selectedDate else { return "Select a Date" } 272 let style = Date.FormatStyle( 273 date: .complete, 274 time: .omitted, 275 locale: .current, 276 calendar: Self.nytCalendar, 277 timeZone: Self.nytTimeZone 278 ) 279 return "Start \(selectedDate.formatted(style))" 280 } 281 282 private var randomButton: some View { 283 HStack(spacing: 4) { 284 Text("Random") 285 weekdayMenu 286 Text("in") 287 yearMenu 288 } 289 .font(.headline) 290 .lineLimit(1) 291 .minimumScaleFactor(0.7) 292 .foregroundStyle(Self.randomButtonForeground) 293 .frame(maxWidth: .infinity) 294 .padding(.vertical, 14) 295 .background(Self.randomButtonTint, in: .capsule) 296 .contentShape(.capsule) 297 .onTapGesture { 298 if let date = randomDate() { 299 fetch(date) 300 } 301 } 302 .accessibilityElement(children: .combine) 303 .accessibilityAddTraits(.isButton) 304 } 305 306 private static let randomButtonTint = Color.accentColor.opacity(0.15) 307 private static let randomButtonForeground = Color.primary 308 309 private var weekdayMenu: some View { 310 Menu { 311 Button("Any Day") { randomWeekday = nil } 312 ForEach(Self.weekdayMenuOrder, id: \.self) { weekday in 313 Button(Self.weekdayName(weekday)) { randomWeekday = weekday } 314 } 315 } label: { 316 menuLabel( 317 randomWeekday.map(Self.shortWeekdayName) ?? "day", 318 reservingWidthFor: Self.weekdayMenuLabelOptions 319 ) 320 } 321 } 322 323 private var yearMenu: some View { 324 Menu { 325 ForEach(Self.selectableYears.reversed(), id: \.self) { year in 326 Button(String(year)) { randomYear = year } 327 } 328 } label: { 329 menuLabel(String(randomYear)) 330 } 331 } 332 333 private static var weekdayMenuLabelOptions: [String] { 334 ["day"] + weekdayMenuOrder.map(shortWeekdayName) 335 } 336 337 private func menuLabel(_ text: String, reservingWidthFor options: [String] = []) -> some View { 338 HStack(spacing: 2) { 339 ZStack { 340 ForEach(options, id: \.self) { option in 341 Text(option) 342 .hidden() 343 } 344 Text(text) 345 } 346 Image(systemName: "chevron.down") 347 .font(.caption2.weight(.bold)) 348 .foregroundStyle(Color.accentColor) 349 } 350 .foregroundStyle(Self.randomButtonForeground) 351 } 352 353 /// Picks a random valid puzzle date in `randomYear`, optionally constrained to 354 /// `randomWeekday`, clamped to the supported range (>= minDate, <= today). 355 private func randomDate() -> Date? { 356 let cal = Self.nytCalendar 357 let minStart = cal.startOfDay(for: Self.minDate) 358 let todayStart = cal.startOfDay(for: Date()) 359 360 var startComps = DateComponents() 361 startComps.year = randomYear 362 startComps.month = 1 363 startComps.day = 1 364 var endComps = DateComponents() 365 endComps.year = randomYear 366 endComps.month = 12 367 endComps.day = 31 368 guard let yearStart = cal.date(from: startComps), 369 let yearEnd = cal.date(from: endComps) else { return nil } 370 371 let lower = max(cal.startOfDay(for: yearStart), minStart) 372 let upper = min(cal.startOfDay(for: yearEnd), todayStart) 373 guard lower <= upper else { return nil } 374 375 var candidates: [Date] = [] 376 var day = lower 377 while day <= upper { 378 let matchesWeekday = randomWeekday.map { cal.component(.weekday, from: day) == $0 } ?? true 379 if matchesWeekday { 380 let dayStart = cal.startOfDay(for: day) 381 if !excludedDates.contains(dayStart) { 382 candidates.append(dayStart) 383 } 384 } 385 guard let next = cal.date(byAdding: .day, value: 1, to: day) else { break } 386 day = next 387 } 388 return candidates.randomElement() 389 } 390 391 private var dateWheelPopover: some View { 392 VStack(spacing: 28) { 393 HStack { 394 Button(role: .cancel) { 395 showingMonthPicker = false 396 } label: { 397 Image(systemName: "xmark") 398 } 399 .accessibilityLabel("Cancel") 400 401 Spacer() 402 403 Button { 404 selectedDate = pickerDate 405 displayedMonth = startOfMonth(for: pickerDate) 406 showingMonthPicker = false 407 } label: { 408 Image(systemName: "checkmark") 409 } 410 .buttonStyle(.borderedProminent) 411 .accessibilityLabel("Done") 412 } 413 414 DatePicker( 415 "Puzzle Date", 416 selection: $pickerDate, 417 in: Self.minDate...Date(), 418 displayedComponents: .date 419 ) 420 .datePickerStyle(.wheel) 421 .labelsHidden() 422 .environment(\.calendar, Self.nytCalendar) 423 .environment(\.timeZone, Self.nytTimeZone) 424 .frame(width: 320, height: 160) 425 .clipped() 426 } 427 .padding(.horizontal) 428 .padding(.vertical, 8) 429 } 430 431 private func isSelected(_ date: Date) -> Bool { 432 guard let selectedDate else { return false } 433 return Self.nytCalendar.isDate(date, inSameDayAs: selectedDate) 434 } 435 436 // MARK: - Month navigation 437 438 private func shiftMonth(by delta: Int) { 439 let cal = Self.nytCalendar 440 if let next = cal.date(byAdding: .month, value: delta, to: displayedMonth) { 441 displayedMonth = next 442 } 443 } 444 445 private func startOfMonth(for date: Date) -> Date { 446 let cal = Self.nytCalendar 447 let components = cal.dateComponents([.year, .month], from: date) 448 return cal.date(from: components) ?? date 449 } 450 451 private var monthTitle: String { 452 let formatter = DateFormatter() 453 formatter.calendar = Self.nytCalendar 454 formatter.timeZone = Self.nytTimeZone 455 formatter.dateFormat = "MMMM yyyy" 456 return formatter.string(from: displayedMonth) 457 } 458 459 private var canGoBack: Bool { 460 let cal = Self.nytCalendar 461 let minMonth = cal.dateComponents([.year, .month], from: Self.minDate) 462 let current = cal.dateComponents([.year, .month], from: displayedMonth) 463 guard let cy = current.year, let cm = current.month, 464 let my = minMonth.year, let mm = minMonth.month else { return false } 465 return (cy, cm) > (my, mm) 466 } 467 468 private var canGoForward: Bool { 469 let cal = Self.nytCalendar 470 let today = cal.dateComponents([.year, .month], from: Date()) 471 let current = cal.dateComponents([.year, .month], from: displayedMonth) 472 guard let cy = current.year, let cm = current.month, 473 let ty = today.year, let tm = today.month else { return false } 474 return (cy, cm) < (ty, tm) 475 } 476 477 // MARK: - Grid 478 479 private var gridCells: [Date?] { 480 var cells: [Date?] = [] 481 let cal = Self.nytCalendar 482 let monthComps = cal.dateComponents([.year, .month], from: displayedMonth) 483 guard let firstOfMonth = cal.date(from: monthComps), 484 let range = cal.range(of: .day, in: .month, for: firstOfMonth) else { 485 return cells 486 } 487 let firstWeekday = cal.component(.weekday, from: firstOfMonth) 488 let leadingBlanks = firstWeekday - 1 489 for _ in 0..<leadingBlanks { cells.append(nil) } 490 for day in range { 491 cells.append(cal.date(byAdding: .day, value: day - 1, to: firstOfMonth)) 492 } 493 while cells.count % 7 != 0 { cells.append(nil) } 494 return cells 495 } 496 497 private func isEnabled(_ date: Date) -> Bool { 498 let cal = Self.nytCalendar 499 let dayStart = cal.startOfDay(for: date) 500 let minDayStart = cal.startOfDay(for: Self.minDate) 501 let todayStart = cal.startOfDay(for: Date()) 502 return dayStart >= minDayStart && dayStart <= todayStart 503 } 504 505 // MARK: - Fetch 506 507 private func fetch(_ date: Date) { 508 guard let fetcher else { 509 errorMessage = "Puzzle fetcher unavailable." 510 return 511 } 512 isLoading = true 513 Task { @MainActor in 514 defer { isLoading = false } 515 do { 516 let source = try await fetcher.fetchPuzzle(for: date) 517 onSelected(source) 518 } catch NYTFetchError.unauthorized { 519 sessionExpired = true 520 } catch { 521 errorMessage = error.localizedDescription 522 } 523 } 524 } 525 }