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 }