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