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