crossmate

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

commit ec59ffe353eeee1ed7f51fe49e594b30797dd41f
parent c55aee18a2ced9073240ccf16761f00334f54c07
Author: Michael Camilleri <[email protected]>
Date:   Sun,  7 Jun 2026 13:54:41 +0900

Add random puzzle button to provider's browse view

This commit adds a secondary button below the Start button that fetches
a random puzzle for a chosen weekday and year. The weekday ('Any Day' or
a full day name) and year are picked via inline menus in the button
label, and the date is randomised within the supported range (>= 2001,
<= today in New York), reusing the existing fetch path.

This commit also has today's date preselected when the view opens
so the Start button is ready without first tapping a cell.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Views/NYTBrowseView.swift | 147++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 146 insertions(+), 1 deletion(-)

diff --git a/Crossmate/Views/NYTBrowseView.swift b/Crossmate/Views/NYTBrowseView.swift @@ -6,18 +6,24 @@ struct NYTBrowseView: View { @Environment(\.nytPuzzleFetcher) private var fetcher @Environment(NYTAuthService.self) private var nytAuth @State private var displayedMonth: Date = NYTBrowseView.startOfCurrentMonth() - @State private var selectedDate: Date? + @State private var selectedDate: Date? = NYTBrowseView.today() @State private var isLoading = false @State private var errorMessage: String? @State private var sessionExpired = false @State private var showingMonthPicker = false @State private var pickerDate: Date = NYTBrowseView.startOfCurrentMonth() + @State private var randomWeekday: Int? = nil + @State private var randomYear: Int = NYTBrowseView.currentYear() private static let nytTimeZone = TimeZone(identifier: "America/New_York")! private static var nytCalendar: Calendar { var cal = Calendar(identifier: .gregorian) cal.timeZone = nytTimeZone + // Without an explicit locale the calendar uses a "fixed" locale whose + // weekdaySymbols are abbreviated ("Mon"); set the current locale so the + // weekday menu shows full names. + cal.locale = .current return cal } @@ -35,6 +41,40 @@ struct NYTBrowseView: View { return cal.date(from: comps) ?? Date() } + private static func today() -> Date { + nytCalendar.startOfDay(for: Date()) + } + + private static func currentYear() -> Int { + nytCalendar.component(.year, from: Date()) + } + + private static let selectableYears: [Int] = { + let start = nytCalendar.component(.year, from: minDate) + let end = nytCalendar.component(.year, from: Date()) + return Array(start...end) + }() + + // Full weekday names (index 0 = Sunday) in the user's locale. Sourced from a + // DateFormatter rather than nytCalendar, whose unset "fixed" locale yields + // abbreviated symbols ("Mon"). + private static let fullWeekdaySymbols: [String] = { + let formatter = DateFormatter() + formatter.locale = .current + return formatter.weekdaySymbols + }() + + private static func weekdayName(_ weekday: Int) -> String { + // weekday uses Gregorian numbering: 1 = Sunday … 7 = Saturday + fullWeekdaySymbols[(weekday - 1) % fullWeekdaySymbols.count] + } + + // Gregorian weekday numbers. The menu opens upward from the bottom-anchored + // button, so the first declared item lands nearest the button (visually at + // the bottom). Declaring Sunday → Monday here yields a top-to-bottom reading + // of Monday … Sunday, with "Any Day" (declared first) pinned to the bottom. + private static let weekdayMenuOrder = [1, 7, 6, 5, 4, 3, 2] + private let columns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) private let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"] @@ -45,6 +85,7 @@ struct NYTBrowseView: View { dayGrid Spacer() confirmButton + randomButton } .padding() .disabled(isLoading) @@ -175,6 +216,110 @@ struct NYTBrowseView: View { return "Start \(selectedDate.formatted(style))" } + private var randomButton: some View { + HStack(spacing: 4) { + Text("Random") + weekdayMenu + Text("in") + yearMenu + } + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + .foregroundStyle(Self.randomButtonForeground) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Self.randomButtonTint, in: .capsule) + .contentShape(.capsule) + .onTapGesture { + if let date = randomDate() { + fetch(date) + } + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + } + + private static let randomButtonTint = Color.accentColor.opacity(0.15) + private static let randomButtonForeground = Color.primary + + private var weekdayMenu: some View { + Menu { + Button("Any Day") { randomWeekday = nil } + ForEach(Self.weekdayMenuOrder, id: \.self) { weekday in + Button(Self.weekdayName(weekday)) { randomWeekday = weekday } + } + } label: { + menuLabel(randomWeekday.map(Self.weekdayName) ?? "day") + } + } + + private var yearMenu: some View { + Menu { + ForEach(Self.selectableYears.reversed(), id: \.self) { year in + Button(String(year)) { randomYear = year } + } + } label: { + menuLabel(String(randomYear)) + } + } + + private func menuLabel(_ text: String) -> some View { + HStack(spacing: 2) { + Text(text) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.accentColor) + .frame(height: 1) + .offset(y: 4) + } + Image(systemName: "chevron.down") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color.accentColor) + } + .foregroundStyle(Self.randomButtonForeground) + } + + /// Picks a random valid puzzle date in `randomYear`, optionally constrained to + /// `randomWeekday`, clamped to the supported range (>= minDate, <= today). + private func randomDate() -> Date? { + let cal = Self.nytCalendar + let minStart = cal.startOfDay(for: Self.minDate) + let todayStart = cal.startOfDay(for: Date()) + + var startComps = DateComponents() + startComps.year = randomYear + startComps.month = 1 + startComps.day = 1 + var endComps = DateComponents() + endComps.year = randomYear + endComps.month = 12 + endComps.day = 31 + guard let yearStart = cal.date(from: startComps), + let yearEnd = cal.date(from: endComps) else { return nil } + + let lower = max(cal.startOfDay(for: yearStart), minStart) + let upper = min(cal.startOfDay(for: yearEnd), todayStart) + guard lower <= upper else { return nil } + + if let weekday = randomWeekday { + var candidates: [Date] = [] + var day = lower + while day <= upper { + if cal.component(.weekday, from: day) == weekday { + candidates.append(day) + } + guard let next = cal.date(byAdding: .day, value: 1, to: day) else { break } + day = next + } + return candidates.randomElement() + } else { + let span = cal.dateComponents([.day], from: lower, to: upper).day ?? 0 + let offset = Int.random(in: 0...max(span, 0)) + return cal.date(byAdding: .day, value: offset, to: lower) + } + } + private var dateWheelPopover: some View { VStack(spacing: 28) { HStack {