crossmate

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

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 }