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