crossmate

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

NewGameSheet.swift (5592B)


      1 import SwiftUI
      2 
      3 struct NewGameSheet: View {
      4     let store: GameStore
      5     var onCreated: (UUID) -> Void = { _ in }
      6 
      7     @Environment(\.dismiss) private var dismiss
      8     @Environment(NYTAuthService.self) private var nytAuth
      9     @Environment(EventLog.self) private var eventLog
     10     @AppStorage("lastPuzzleSource") private var storedSourceRaw = PuzzleSource.bundles.rawValue
     11     @AppStorage("debugMode") private var debugMode = false
     12     @State private var selection: PuzzleSource = .bundles
     13     @State private var duplicateSource: String?
     14     @State private var createError: CreateError?
     15 
     16     private struct CreateError: Identifiable {
     17         let id = UUID()
     18         let title: String
     19         let message: String
     20     }
     21 
     22     private var availableSources: [PuzzleSource] {
     23         var sources: [PuzzleSource] = [.bundles]
     24         if debugMode {
     25             sources.append(.debug)
     26         }
     27         sources.append(.imported)
     28         if nytAuth.canAttemptNYTFetch {
     29             sources.append(.nyt)
     30         }
     31         return sources
     32     }
     33 
     34     var body: some View {
     35         NavigationStack {
     36             VStack(spacing: 0) {
     37                 Picker("Source", selection: $selection) {
     38                     ForEach(availableSources) { source in
     39                         Text(source.title).tag(source)
     40                     }
     41                 }
     42                 .pickerStyle(.segmented)
     43                 .padding()
     44 
     45                 Group {
     46                     switch selection {
     47                     case .bundles:
     48                         BundledBrowseView(onSelected: handleSelected)
     49                     case .debug:
     50                         DebugBrowseView(onSelected: handleSelected)
     51                     case .imported:
     52                         ImportedBrowseView(onSelected: handleSelected)
     53                     case .nyt:
     54                         NYTBrowseView(
     55                             onSelected: handleSelected,
     56                             excludedDates: store.nytPuzzleDatesInLibrary()
     57                         )
     58                     }
     59                 }
     60             }
     61             .navigationTitle("New Puzzle")
     62             .navigationBarTitleDisplayMode(.inline)
     63             .toolbar {
     64                 ToolbarItem(placement: .cancellationAction) {
     65                     Button {
     66                         dismiss()
     67                     } label: {
     68                         Image(systemName: "xmark")
     69                     }
     70                     .accessibilityLabel("Cancel")
     71                 }
     72             }
     73         }
     74         .onAppear {
     75             // Fall back to .bundles if the stored raw value is missing or no
     76             // longer maps to a case (e.g. the renamed "bundled" -> "bundles").
     77             let stored = PuzzleSource(rawValue: storedSourceRaw) ?? .bundles
     78             selection = availableSources.contains(stored) ? stored : .bundles
     79         }
     80         .onChange(of: selection) { _, newValue in
     81             storedSourceRaw = newValue.rawValue
     82         }
     83         .alert(
     84             "Puzzle Already in Library",
     85             isPresented: .init(
     86                 get: { duplicateSource != nil },
     87                 set: { if !$0 { duplicateSource = nil } }
     88             ),
     89             presenting: duplicateSource
     90         ) { source in
     91             Button("Create Copy") {
     92                 create(from: source)
     93             }
     94             Button("Cancel", role: .cancel) {}
     95         } message: { _ in
     96             Text("You already have a copy of this puzzle in your library. Do you want to create a new copy?")
     97         }
     98         .alert(
     99             createError?.title ?? "Couldn't Create Puzzle",
    100             isPresented: .init(
    101                 get: { createError != nil },
    102                 set: { if !$0 { createError = nil } }
    103             ),
    104             presenting: createError
    105         ) { _ in
    106             Button("OK", role: .cancel) {}
    107         } message: { error in
    108             Text(error.message)
    109         }
    110     }
    111 
    112     private func handleSelected(_ source: String) {
    113         if store.findGameID(matching: source) != nil {
    114             duplicateSource = source
    115         } else {
    116             create(from: source)
    117         }
    118     }
    119 
    120     private func create(from source: String) {
    121         do {
    122             let gameID = try store.createGame(from: source)
    123             dismiss()
    124             onCreated(gameID)
    125         } catch let parseError as XD.ParseError {
    126             // Keep the alert plain-language and date-stamped; the specifics that
    127             // pin down the converter bug — which character, which clue — go to
    128             // the diagnostic log, which is what a tester actually shares back.
    129             let lead = XD.metadataValue("Date", in: source).map { "The puzzle for \($0)" } ?? "This puzzle"
    130             createError = CreateError(
    131                 title: "Could Not Parse Puzzle",
    132                 message: "\(lead) \(parseError.userFacingReason)."
    133             )
    134             eventLog.note(
    135                 "new puzzle parse failed [\(Self.puzzleDescriptor(from: source))]: \(parseError.description)",
    136                 level: "error"
    137             )
    138         } catch {
    139             createError = CreateError(title: "Couldn't Create Puzzle", message: error.localizedDescription)
    140         }
    141     }
    142 
    143     /// A compact one-line identification of the puzzle for the diagnostic log,
    144     /// built from whatever metadata headers survived in the raw source.
    145     private static func puzzleDescriptor(from source: String) -> String {
    146         let parts = ["Publisher", "Title", "Date"].compactMap { XD.metadataValue($0, in: source) }
    147         return parts.isEmpty ? "unknown puzzle" : parts.joined(separator: " · ")
    148     }
    149 }