NYTBrowseView.swift (19433B)
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("Set up an external puzzle provider in 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 .accessibilityLabel("Previous Month") 193 194 Spacer() 195 196 Button { 197 pickerDate = selectedDate ?? displayedMonth 198 showingMonthPicker = true 199 } label: { 200 HStack(spacing: 4) { 201 Text(monthTitle) 202 Image(systemName: "chevron.down") 203 .font(.caption.weight(.semibold)) 204 } 205 .font(.headline) 206 } 207 .buttonStyle(.plain) 208 .accessibilityLabel("Choose Month") 209 .accessibilityValue(monthTitle) 210 .popover(isPresented: $showingMonthPicker) { 211 dateWheelPopover 212 .presentationCompactAdaptation(.popover) 213 } 214 215 Spacer() 216 217 Button { 218 shiftMonth(by: 1) 219 } label: { 220 Image(systemName: "chevron.right") 221 .font(.title3) 222 } 223 .disabled(!canGoForward) 224 .accessibilityLabel("Next Month") 225 } 226 } 227 228 private var weekdayHeader: some View { 229 HStack(spacing: 0) { 230 ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in 231 Text(symbol) 232 .font(.caption) 233 .foregroundStyle(.secondary) 234 .frame(maxWidth: .infinity) 235 } 236 } 237 } 238 239 private var dayGrid: some View { 240 LazyVGrid(columns: columns, spacing: 4) { 241 ForEach(Array(gridCells.enumerated()), id: \.offset) { _, cell in 242 if let date = cell { 243 let cal = Self.nytCalendar 244 let dayNumber = cal.component(.day, from: date) 245 CalendarDayCell( 246 date: date, 247 dayNumber: dayNumber, 248 isEnabled: isEnabled(date), 249 isToday: cal.isDateInToday(date), 250 isSelected: isSelected(date), 251 onTap: { selectedDate = date } 252 ) 253 } else { 254 Color.clear.frame(minHeight: 44) 255 } 256 } 257 } 258 } 259 260 private var confirmButton: some View { 261 Button { 262 if let selectedDate { 263 fetch(selectedDate) 264 } 265 } label: { 266 Text(confirmButtonTitle) 267 .font(.headline) 268 .frame(maxWidth: .infinity) 269 } 270 .buttonStyle(.borderedProminent) 271 .controlSize(.large) 272 .disabled(selectedDate == nil) 273 } 274 275 private var confirmButtonTitle: String { 276 guard let selectedDate else { return "Select a Date" } 277 let style = Date.FormatStyle( 278 date: .complete, 279 time: .omitted, 280 locale: .current, 281 calendar: Self.nytCalendar, 282 timeZone: Self.nytTimeZone 283 ) 284 return "Start \(selectedDate.formatted(style))" 285 } 286 287 private var randomButton: some View { 288 HStack(spacing: 12) { 289 HStack(spacing: 4) { 290 Text("Random") 291 .accessibilityHidden(true) 292 weekdayMenu 293 Text("in") 294 .accessibilityHidden(true) 295 yearMenu 296 } 297 .font(.headline) 298 .lineLimit(1) 299 .minimumScaleFactor(0.7) 300 .foregroundStyle(Self.randomButtonForeground) 301 .frame(maxWidth: .infinity) 302 .padding(.vertical, 14) 303 .padding(.horizontal, 16) 304 .background(Self.randomButtonConfigTint, in: .capsule) 305 306 Button { 307 if let date = randomDate() { 308 fetch(date) 309 } 310 } label: { 311 Image(systemName: "shuffle") 312 .font(.title3) 313 .foregroundStyle(Color.accentColor) 314 .frame(width: 52, height: 52) 315 .background(Self.randomButtonTint, in: .circle) 316 .contentShape(.circle) 317 } 318 .buttonStyle(.plain) 319 .accessibilityLabel("Fetch Random Puzzle") 320 .accessibilityValue(randomSelectionAccessibilityValue) 321 .accessibilityHint("Creates a random puzzle using the selected day and year") 322 } 323 } 324 325 private var randomSelectionAccessibilityValue: String { 326 let day = randomWeekday.map(Self.weekdayName) ?? "Any Day" 327 return "\(day), \(randomYear)" 328 } 329 330 private static let randomButtonTint = Color.accentColor.opacity(0.15) 331 private static let randomButtonConfigTint = Color(.tertiarySystemFill) 332 private static let randomButtonForeground = Color.primary 333 334 private var weekdayMenu: some View { 335 Menu { 336 Button("Any Day") { randomWeekday = nil } 337 ForEach(Self.weekdayMenuOrder, id: \.self) { weekday in 338 Button(Self.weekdayName(weekday)) { randomWeekday = weekday } 339 } 340 } label: { 341 menuLabel( 342 randomWeekday.map(Self.shortWeekdayName) ?? "day", 343 reservingWidthFor: Self.weekdayMenuLabelOptions 344 ) 345 } 346 .accessibilityLabel("Random Day") 347 .accessibilityValue(randomWeekday.map(Self.weekdayName) ?? "Any Day") 348 .accessibilityHint("Chooses the weekday for random puzzles") 349 } 350 351 private var yearMenu: some View { 352 Menu { 353 ForEach(Self.selectableYears.reversed(), id: \.self) { year in 354 Button(String(year)) { randomYear = year } 355 } 356 } label: { 357 menuLabel(String(randomYear)) 358 } 359 .accessibilityLabel("Random Year") 360 .accessibilityValue(String(randomYear)) 361 .accessibilityHint("Chooses the year for random puzzles") 362 } 363 364 private static var weekdayMenuLabelOptions: [String] { 365 ["day"] + weekdayMenuOrder.map(shortWeekdayName) 366 } 367 368 private func menuLabel(_ text: String, reservingWidthFor options: [String] = []) -> some View { 369 HStack(spacing: 2) { 370 ZStack { 371 ForEach(options, id: \.self) { option in 372 Text(option) 373 .hidden() 374 } 375 Text(text) 376 } 377 Image(systemName: "chevron.down") 378 .font(.caption2.weight(.bold)) 379 .foregroundStyle(Color.accentColor) 380 } 381 .foregroundStyle(Self.randomButtonForeground) 382 } 383 384 /// Picks a random valid puzzle date in `randomYear`, optionally constrained to 385 /// `randomWeekday`, clamped to the supported range (>= minDate, <= today). 386 private func randomDate() -> Date? { 387 let cal = Self.nytCalendar 388 let minStart = cal.startOfDay(for: Self.minDate) 389 let todayStart = cal.startOfDay(for: Date()) 390 391 var startComps = DateComponents() 392 startComps.year = randomYear 393 startComps.month = 1 394 startComps.day = 1 395 var endComps = DateComponents() 396 endComps.year = randomYear 397 endComps.month = 12 398 endComps.day = 31 399 guard let yearStart = cal.date(from: startComps), 400 let yearEnd = cal.date(from: endComps) else { return nil } 401 402 let lower = max(cal.startOfDay(for: yearStart), minStart) 403 let upper = min(cal.startOfDay(for: yearEnd), todayStart) 404 guard lower <= upper else { return nil } 405 406 var candidates: [Date] = [] 407 var day = lower 408 while day <= upper { 409 let matchesWeekday = randomWeekday.map { cal.component(.weekday, from: day) == $0 } ?? true 410 if matchesWeekday { 411 let dayStart = cal.startOfDay(for: day) 412 if !excludedDates.contains(dayStart) { 413 candidates.append(dayStart) 414 } 415 } 416 guard let next = cal.date(byAdding: .day, value: 1, to: day) else { break } 417 day = next 418 } 419 return candidates.randomElement() 420 } 421 422 private var dateWheelPopover: some View { 423 VStack(spacing: 28) { 424 HStack { 425 Button(role: .cancel) { 426 showingMonthPicker = false 427 } label: { 428 Image(systemName: "xmark") 429 } 430 .accessibilityLabel("Cancel") 431 432 Spacer() 433 434 Button { 435 selectedDate = pickerDate 436 displayedMonth = startOfMonth(for: pickerDate) 437 showingMonthPicker = false 438 } label: { 439 Image(systemName: "checkmark") 440 } 441 .buttonStyle(.borderedProminent) 442 .accessibilityLabel("Done") 443 } 444 445 DatePicker( 446 "Puzzle Date", 447 selection: $pickerDate, 448 in: Self.minDate...Date(), 449 displayedComponents: .date 450 ) 451 .datePickerStyle(.wheel) 452 .labelsHidden() 453 .environment(\.calendar, Self.nytCalendar) 454 .environment(\.timeZone, Self.nytTimeZone) 455 .frame(width: 320, height: 160) 456 .clipped() 457 } 458 .padding(.horizontal) 459 .padding(.vertical, 8) 460 } 461 462 private func isSelected(_ date: Date) -> Bool { 463 guard let selectedDate else { return false } 464 return Self.nytCalendar.isDate(date, inSameDayAs: selectedDate) 465 } 466 467 // MARK: - Month navigation 468 469 private func shiftMonth(by delta: Int) { 470 let cal = Self.nytCalendar 471 if let next = cal.date(byAdding: .month, value: delta, to: displayedMonth) { 472 displayedMonth = next 473 } 474 } 475 476 private func startOfMonth(for date: Date) -> Date { 477 let cal = Self.nytCalendar 478 let components = cal.dateComponents([.year, .month], from: date) 479 return cal.date(from: components) ?? date 480 } 481 482 private var monthTitle: String { 483 let formatter = DateFormatter() 484 formatter.calendar = Self.nytCalendar 485 formatter.timeZone = Self.nytTimeZone 486 formatter.dateFormat = "MMMM yyyy" 487 return formatter.string(from: displayedMonth) 488 } 489 490 private var canGoBack: Bool { 491 let cal = Self.nytCalendar 492 let minMonth = cal.dateComponents([.year, .month], from: Self.minDate) 493 let current = cal.dateComponents([.year, .month], from: displayedMonth) 494 guard let cy = current.year, let cm = current.month, 495 let my = minMonth.year, let mm = minMonth.month else { return false } 496 return (cy, cm) > (my, mm) 497 } 498 499 private var canGoForward: Bool { 500 let cal = Self.nytCalendar 501 let today = cal.dateComponents([.year, .month], from: Date()) 502 let current = cal.dateComponents([.year, .month], from: displayedMonth) 503 guard let cy = current.year, let cm = current.month, 504 let ty = today.year, let tm = today.month else { return false } 505 return (cy, cm) < (ty, tm) 506 } 507 508 // MARK: - Grid 509 510 private var gridCells: [Date?] { 511 var cells: [Date?] = [] 512 let cal = Self.nytCalendar 513 let monthComps = cal.dateComponents([.year, .month], from: displayedMonth) 514 guard let firstOfMonth = cal.date(from: monthComps), 515 let range = cal.range(of: .day, in: .month, for: firstOfMonth) else { 516 return cells 517 } 518 let firstWeekday = cal.component(.weekday, from: firstOfMonth) 519 let leadingBlanks = firstWeekday - 1 520 for _ in 0..<leadingBlanks { cells.append(nil) } 521 for day in range { 522 cells.append(cal.date(byAdding: .day, value: day - 1, to: firstOfMonth)) 523 } 524 while cells.count % 7 != 0 { cells.append(nil) } 525 return cells 526 } 527 528 private func isEnabled(_ date: Date) -> Bool { 529 let cal = Self.nytCalendar 530 let dayStart = cal.startOfDay(for: date) 531 let minDayStart = cal.startOfDay(for: Self.minDate) 532 let todayStart = cal.startOfDay(for: Date()) 533 return dayStart >= minDayStart && dayStart <= todayStart 534 } 535 536 // MARK: - Fetch 537 538 private func fetch(_ date: Date) { 539 guard let fetcher else { 540 errorMessage = "Puzzle fetcher unavailable." 541 return 542 } 543 isLoading = true 544 Task { @MainActor in 545 defer { isLoading = false } 546 do { 547 let source = try await fetcher.fetchPuzzle(for: date) 548 onSelected(source) 549 } catch NYTFetchError.unauthorized { 550 sessionExpired = true 551 } catch { 552 errorMessage = error.localizedDescription 553 } 554 } 555 } 556 }