crossmate

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

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 }