crossmate

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

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 }