crossmate

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

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 }