NYTBrowseView.swift (18635B)
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: 12) { 284 HStack(spacing: 4) { 285 Text("Random") 286 weekdayMenu 287 Text("in") 288 yearMenu 289 } 290 .font(.headline) 291 .lineLimit(1) 292 .minimumScaleFactor(0.7) 293 .foregroundStyle(Self.randomButtonForeground) 294 .frame(maxWidth: .infinity) 295 .padding(.vertical, 14) 296 .padding(.horizontal, 16) 297 .background(Self.randomButtonConfigTint, in: .capsule) 298 299 Button { 300 if let date = randomDate() { 301 fetch(date) 302 } 303 } label: { 304 Image(systemName: "shuffle") 305 .font(.title3) 306 .foregroundStyle(Color.accentColor) 307 .frame(width: 52, height: 52) 308 .background(Self.randomButtonTint, in: .circle) 309 .contentShape(.circle) 310 } 311 .buttonStyle(.plain) 312 .accessibilityLabel(randomButtonAccessibilityLabel) 313 } 314 } 315 316 private var randomButtonAccessibilityLabel: String { 317 let scope = randomWeekday.map { "\(Self.weekdayName($0)) " } ?? "" 318 return "Fetch a random \(scope)puzzle from \(randomYear)" 319 } 320 321 private static let randomButtonTint = Color.accentColor.opacity(0.15) 322 private static let randomButtonConfigTint = Color(.tertiarySystemFill) 323 private static let randomButtonForeground = Color.primary 324 325 private var weekdayMenu: some View { 326 Menu { 327 Button("Any Day") { randomWeekday = nil } 328 ForEach(Self.weekdayMenuOrder, id: \.self) { weekday in 329 Button(Self.weekdayName(weekday)) { randomWeekday = weekday } 330 } 331 } label: { 332 menuLabel( 333 randomWeekday.map(Self.shortWeekdayName) ?? "day", 334 reservingWidthFor: Self.weekdayMenuLabelOptions 335 ) 336 } 337 } 338 339 private var yearMenu: some View { 340 Menu { 341 ForEach(Self.selectableYears.reversed(), id: \.self) { year in 342 Button(String(year)) { randomYear = year } 343 } 344 } label: { 345 menuLabel(String(randomYear)) 346 } 347 } 348 349 private static var weekdayMenuLabelOptions: [String] { 350 ["day"] + weekdayMenuOrder.map(shortWeekdayName) 351 } 352 353 private func menuLabel(_ text: String, reservingWidthFor options: [String] = []) -> some View { 354 HStack(spacing: 2) { 355 ZStack { 356 ForEach(options, id: \.self) { option in 357 Text(option) 358 .hidden() 359 } 360 Text(text) 361 } 362 Image(systemName: "chevron.down") 363 .font(.caption2.weight(.bold)) 364 .foregroundStyle(Color.accentColor) 365 } 366 .foregroundStyle(Self.randomButtonForeground) 367 } 368 369 /// Picks a random valid puzzle date in `randomYear`, optionally constrained to 370 /// `randomWeekday`, clamped to the supported range (>= minDate, <= today). 371 private func randomDate() -> Date? { 372 let cal = Self.nytCalendar 373 let minStart = cal.startOfDay(for: Self.minDate) 374 let todayStart = cal.startOfDay(for: Date()) 375 376 var startComps = DateComponents() 377 startComps.year = randomYear 378 startComps.month = 1 379 startComps.day = 1 380 var endComps = DateComponents() 381 endComps.year = randomYear 382 endComps.month = 12 383 endComps.day = 31 384 guard let yearStart = cal.date(from: startComps), 385 let yearEnd = cal.date(from: endComps) else { return nil } 386 387 let lower = max(cal.startOfDay(for: yearStart), minStart) 388 let upper = min(cal.startOfDay(for: yearEnd), todayStart) 389 guard lower <= upper else { return nil } 390 391 var candidates: [Date] = [] 392 var day = lower 393 while day <= upper { 394 let matchesWeekday = randomWeekday.map { cal.component(.weekday, from: day) == $0 } ?? true 395 if matchesWeekday { 396 let dayStart = cal.startOfDay(for: day) 397 if !excludedDates.contains(dayStart) { 398 candidates.append(dayStart) 399 } 400 } 401 guard let next = cal.date(byAdding: .day, value: 1, to: day) else { break } 402 day = next 403 } 404 return candidates.randomElement() 405 } 406 407 private var dateWheelPopover: some View { 408 VStack(spacing: 28) { 409 HStack { 410 Button(role: .cancel) { 411 showingMonthPicker = false 412 } label: { 413 Image(systemName: "xmark") 414 } 415 .accessibilityLabel("Cancel") 416 417 Spacer() 418 419 Button { 420 selectedDate = pickerDate 421 displayedMonth = startOfMonth(for: pickerDate) 422 showingMonthPicker = false 423 } label: { 424 Image(systemName: "checkmark") 425 } 426 .buttonStyle(.borderedProminent) 427 .accessibilityLabel("Done") 428 } 429 430 DatePicker( 431 "Puzzle Date", 432 selection: $pickerDate, 433 in: Self.minDate...Date(), 434 displayedComponents: .date 435 ) 436 .datePickerStyle(.wheel) 437 .labelsHidden() 438 .environment(\.calendar, Self.nytCalendar) 439 .environment(\.timeZone, Self.nytTimeZone) 440 .frame(width: 320, height: 160) 441 .clipped() 442 } 443 .padding(.horizontal) 444 .padding(.vertical, 8) 445 } 446 447 private func isSelected(_ date: Date) -> Bool { 448 guard let selectedDate else { return false } 449 return Self.nytCalendar.isDate(date, inSameDayAs: selectedDate) 450 } 451 452 // MARK: - Month navigation 453 454 private func shiftMonth(by delta: Int) { 455 let cal = Self.nytCalendar 456 if let next = cal.date(byAdding: .month, value: delta, to: displayedMonth) { 457 displayedMonth = next 458 } 459 } 460 461 private func startOfMonth(for date: Date) -> Date { 462 let cal = Self.nytCalendar 463 let components = cal.dateComponents([.year, .month], from: date) 464 return cal.date(from: components) ?? date 465 } 466 467 private var monthTitle: String { 468 let formatter = DateFormatter() 469 formatter.calendar = Self.nytCalendar 470 formatter.timeZone = Self.nytTimeZone 471 formatter.dateFormat = "MMMM yyyy" 472 return formatter.string(from: displayedMonth) 473 } 474 475 private var canGoBack: Bool { 476 let cal = Self.nytCalendar 477 let minMonth = cal.dateComponents([.year, .month], from: Self.minDate) 478 let current = cal.dateComponents([.year, .month], from: displayedMonth) 479 guard let cy = current.year, let cm = current.month, 480 let my = minMonth.year, let mm = minMonth.month else { return false } 481 return (cy, cm) > (my, mm) 482 } 483 484 private var canGoForward: Bool { 485 let cal = Self.nytCalendar 486 let today = cal.dateComponents([.year, .month], from: Date()) 487 let current = cal.dateComponents([.year, .month], from: displayedMonth) 488 guard let cy = current.year, let cm = current.month, 489 let ty = today.year, let tm = today.month else { return false } 490 return (cy, cm) < (ty, tm) 491 } 492 493 // MARK: - Grid 494 495 private var gridCells: [Date?] { 496 var cells: [Date?] = [] 497 let cal = Self.nytCalendar 498 let monthComps = cal.dateComponents([.year, .month], from: displayedMonth) 499 guard let firstOfMonth = cal.date(from: monthComps), 500 let range = cal.range(of: .day, in: .month, for: firstOfMonth) else { 501 return cells 502 } 503 let firstWeekday = cal.component(.weekday, from: firstOfMonth) 504 let leadingBlanks = firstWeekday - 1 505 for _ in 0..<leadingBlanks { cells.append(nil) } 506 for day in range { 507 cells.append(cal.date(byAdding: .day, value: day - 1, to: firstOfMonth)) 508 } 509 while cells.count % 7 != 0 { cells.append(nil) } 510 return cells 511 } 512 513 private func isEnabled(_ date: Date) -> Bool { 514 let cal = Self.nytCalendar 515 let dayStart = cal.startOfDay(for: date) 516 let minDayStart = cal.startOfDay(for: Self.minDate) 517 let todayStart = cal.startOfDay(for: Date()) 518 return dayStart >= minDayStart && dayStart <= todayStart 519 } 520 521 // MARK: - Fetch 522 523 private func fetch(_ date: Date) { 524 guard let fetcher else { 525 errorMessage = "Puzzle fetcher unavailable." 526 return 527 } 528 isLoading = true 529 Task { @MainActor in 530 defer { isLoading = false } 531 do { 532 let source = try await fetcher.fetchPuzzle(for: date) 533 onSelected(source) 534 } catch NYTFetchError.unauthorized { 535 sessionExpired = true 536 } catch { 537 errorMessage = error.localizedDescription 538 } 539 } 540 } 541 }