crossmate

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

NYTBrowseView.swift (8135B)


      1 import SwiftUI
      2 
      3 struct NYTBrowseView: View {
      4     let onSelected: (String) -> Void
      5 
      6     @Environment(\.nytPuzzleFetcher) private var fetcher
      7     @Environment(NYTAuthService.self) private var nytAuth
      8     @State private var displayedMonth: Date = NYTBrowseView.startOfCurrentMonth()
      9     @State private var selectedDate: Date?
     10     @State private var isLoading = false
     11     @State private var errorMessage: String?
     12     @State private var sessionExpired = false
     13 
     14     private static let nytTimeZone = TimeZone(identifier: "America/New_York")!
     15 
     16     private static var nytCalendar: Calendar {
     17         var cal = Calendar(identifier: .gregorian)
     18         cal.timeZone = nytTimeZone
     19         return cal
     20     }
     21 
     22     private static let minDate: Date = {
     23         var comps = DateComponents()
     24         comps.year = 2001
     25         comps.month = 1
     26         comps.day = 1
     27         return nytCalendar.date(from: comps)!
     28     }()
     29 
     30     private static func startOfCurrentMonth() -> Date {
     31         let cal = nytCalendar
     32         let comps = cal.dateComponents([.year, .month], from: Date())
     33         return cal.date(from: comps) ?? Date()
     34     }
     35 
     36     private let columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
     37     private let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"]
     38 
     39     var body: some View {
     40         VStack(spacing: 16) {
     41             monthHeader
     42             weekdayHeader
     43             dayGrid
     44             Spacer()
     45             confirmButton
     46         }
     47         .padding()
     48         .disabled(isLoading)
     49         .overlay {
     50             if isLoading {
     51                 ProgressView("Fetching puzzle…")
     52                     .padding()
     53                     .background(.regularMaterial, in: .rect(cornerRadius: 12))
     54             }
     55         }
     56         .alert(
     57             "Couldn't Fetch Puzzle",
     58             isPresented: .init(
     59                 get: { errorMessage != nil },
     60                 set: { if !$0 { errorMessage = nil } }
     61             ),
     62             presenting: errorMessage
     63         ) { _ in
     64             Button("OK", role: .cancel) {}
     65         } message: { message in
     66             Text(message)
     67         }
     68         .alert("NYT Session Expired", isPresented: $sessionExpired) {
     69             Button("OK", role: .cancel) {
     70                 nytAuth.signOut()
     71             }
     72         } message: {
     73             Text("Your NYT session has expired. Sign in again from Settings to resume fetching puzzles.")
     74         }
     75     }
     76 
     77     private var monthHeader: some View {
     78         HStack {
     79             Button {
     80                 shiftMonth(by: -1)
     81             } label: {
     82                 Image(systemName: "chevron.left")
     83                     .font(.title3)
     84             }
     85             .disabled(!canGoBack)
     86 
     87             Spacer()
     88 
     89             Text(monthTitle)
     90                 .font(.headline)
     91 
     92             Spacer()
     93 
     94             Button {
     95                 shiftMonth(by: 1)
     96             } label: {
     97                 Image(systemName: "chevron.right")
     98                     .font(.title3)
     99             }
    100             .disabled(!canGoForward)
    101         }
    102     }
    103 
    104     private var weekdayHeader: some View {
    105         HStack(spacing: 0) {
    106             ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in
    107                 Text(symbol)
    108                     .font(.caption)
    109                     .foregroundStyle(.secondary)
    110                     .frame(maxWidth: .infinity)
    111             }
    112         }
    113     }
    114 
    115     private var dayGrid: some View {
    116         LazyVGrid(columns: columns, spacing: 4) {
    117             ForEach(Array(gridCells.enumerated()), id: \.offset) { _, cell in
    118                 if let date = cell {
    119                     let cal = Self.nytCalendar
    120                     let dayNumber = cal.component(.day, from: date)
    121                     CalendarDayCell(
    122                         dayNumber: dayNumber,
    123                         isEnabled: isEnabled(date),
    124                         isToday: cal.isDateInToday(date),
    125                         isSelected: isSelected(date),
    126                         onTap: { selectedDate = date }
    127                     )
    128                 } else {
    129                     Color.clear.frame(minHeight: 44)
    130                 }
    131             }
    132         }
    133     }
    134 
    135     private var confirmButton: some View {
    136         Button {
    137             if let selectedDate {
    138                 fetch(selectedDate)
    139             }
    140         } label: {
    141             Text(confirmButtonTitle)
    142                 .font(.headline)
    143                 .frame(maxWidth: .infinity)
    144         }
    145         .buttonStyle(.borderedProminent)
    146         .controlSize(.large)
    147         .disabled(selectedDate == nil)
    148     }
    149 
    150     private var confirmButtonTitle: String {
    151         guard let selectedDate else { return "Select a Date" }
    152         let style = Date.FormatStyle(
    153             date: .complete,
    154             time: .omitted,
    155             locale: .current,
    156             calendar: Self.nytCalendar,
    157             timeZone: Self.nytTimeZone
    158         )
    159         return "Start \(selectedDate.formatted(style))"
    160     }
    161 
    162     private func isSelected(_ date: Date) -> Bool {
    163         guard let selectedDate else { return false }
    164         return Self.nytCalendar.isDate(date, inSameDayAs: selectedDate)
    165     }
    166 
    167     // MARK: - Month navigation
    168 
    169     private func shiftMonth(by delta: Int) {
    170         let cal = Self.nytCalendar
    171         if let next = cal.date(byAdding: .month, value: delta, to: displayedMonth) {
    172             displayedMonth = next
    173         }
    174     }
    175 
    176     private var monthTitle: String {
    177         let formatter = DateFormatter()
    178         formatter.calendar = Self.nytCalendar
    179         formatter.timeZone = Self.nytTimeZone
    180         formatter.dateFormat = "MMMM yyyy"
    181         return formatter.string(from: displayedMonth)
    182     }
    183 
    184     private var canGoBack: Bool {
    185         let cal = Self.nytCalendar
    186         let minMonth = cal.dateComponents([.year, .month], from: Self.minDate)
    187         let current = cal.dateComponents([.year, .month], from: displayedMonth)
    188         guard let cy = current.year, let cm = current.month,
    189               let my = minMonth.year, let mm = minMonth.month else { return false }
    190         return (cy, cm) > (my, mm)
    191     }
    192 
    193     private var canGoForward: Bool {
    194         let cal = Self.nytCalendar
    195         let today = cal.dateComponents([.year, .month], from: Date())
    196         let current = cal.dateComponents([.year, .month], from: displayedMonth)
    197         guard let cy = current.year, let cm = current.month,
    198               let ty = today.year, let tm = today.month else { return false }
    199         return (cy, cm) < (ty, tm)
    200     }
    201 
    202     // MARK: - Grid
    203 
    204     private var gridCells: [Date?] {
    205         var cells: [Date?] = []
    206         let cal = Self.nytCalendar
    207         let monthComps = cal.dateComponents([.year, .month], from: displayedMonth)
    208         guard let firstOfMonth = cal.date(from: monthComps),
    209               let range = cal.range(of: .day, in: .month, for: firstOfMonth) else {
    210             return cells
    211         }
    212         let firstWeekday = cal.component(.weekday, from: firstOfMonth)
    213         let leadingBlanks = firstWeekday - 1
    214         for _ in 0..<leadingBlanks { cells.append(nil) }
    215         for day in range {
    216             cells.append(cal.date(byAdding: .day, value: day - 1, to: firstOfMonth))
    217         }
    218         while cells.count % 7 != 0 { cells.append(nil) }
    219         return cells
    220     }
    221 
    222     private func isEnabled(_ date: Date) -> Bool {
    223         let cal = Self.nytCalendar
    224         let dayStart = cal.startOfDay(for: date)
    225         let minDayStart = cal.startOfDay(for: Self.minDate)
    226         let todayStart = cal.startOfDay(for: Date())
    227         return dayStart >= minDayStart && dayStart <= todayStart
    228     }
    229 
    230     // MARK: - Fetch
    231 
    232     private func fetch(_ date: Date) {
    233         guard let fetcher else {
    234             errorMessage = "Puzzle fetcher unavailable."
    235             return
    236         }
    237         isLoading = true
    238         Task { @MainActor in
    239             defer { isLoading = false }
    240             do {
    241                 let source = try await fetcher.fetchPuzzle(for: date)
    242                 onSelected(source)
    243             } catch NYTFetchError.unauthorized {
    244                 sessionExpired = true
    245             } catch {
    246                 errorMessage = error.localizedDescription
    247             }
    248         }
    249     }
    250 }