crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }