crossmate

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

NYTBrowseView.swift (17913B)


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