SettingsView.swift (8687B)
1 import SwiftUI 2 3 struct SettingsView: View { 4 @Environment(NYTAuthService.self) private var nytAuth 5 @Environment(PlayerPreferences.self) private var preferences 6 @Environment(\.dismiss) private var dismiss 7 @Environment(\.resetDatabase) private var resetDatabase 8 9 @AppStorage("debugMode") private var debugMode = false 10 @State private var showingNYTLogin = false 11 @State private var showResetConfirmation = false 12 @State private var externalSource: ExternalSource? 13 @State private var easterEggTaps = 0 14 15 var body: some View { 16 @Bindable var preferences = preferences 17 NavigationStack { 18 Form { 19 Section { 20 NavigationLink { 21 ProfileNameEditView() 22 } label: { 23 HStack { 24 Text("Name") 25 Spacer() 26 Text(preferences.name) 27 .foregroundStyle(.secondary) 28 .lineLimit(1) 29 } 30 } 31 } header: { 32 Text("Profile") 33 } 34 35 Section("External Source") { 36 Picker("Publisher", selection: $externalSource) { 37 Text("None").tag(nil as ExternalSource?) 38 ForEach(ExternalSource.allCases) { source in 39 Text(source.title).tag(source as ExternalSource?) 40 } 41 } 42 .pickerStyle(.menu) 43 44 switch externalSource { 45 case nil: 46 EmptyView() 47 case .newYorkTimes: 48 switch nytAuth.sessionState { 49 case .signedIn: 50 signedInView 51 case .signedOut: 52 signInView 53 case .unknown: 54 unknownSessionView 55 } 56 } 57 } 58 59 Section { 60 Toggle("Started Solving", isOn: $preferences.notifiesSessionBegin) 61 Toggle("Stopped Solving", isOn: $preferences.notifiesSessionEnd) 62 Toggle("Puzzle Invited", isOn: $preferences.notifiesInvites) 63 Toggle("Puzzle Finished", isOn: $preferences.notifiesPuzzleFinished) 64 } header: { 65 Text("Notifications") 66 } footer: { 67 Text("Receive notifications when friends start solving, stop solving, invite you to or finish a shared puzzle. These settings apply only to this device.") 68 } 69 70 if debugMode { 71 Section("Debugging") { 72 Toggle("Enable iCloud Sync", isOn: $preferences.isICloudSyncEnabled) 73 74 NavigationLink("Diagnostics Log") { 75 DiagnosticsView() 76 } 77 NavigationLink("Record Editor") { 78 RecordEditorView() 79 } 80 81 Button("Reset Database", role: .destructive) { 82 showResetConfirmation = true 83 } 84 .alert( 85 "Delete all games?", 86 isPresented: $showResetConfirmation 87 ) { 88 Button("Delete All Games", role: .destructive) { 89 Task { await resetDatabase?() } 90 } 91 Button("Cancel", role: .cancel) {} 92 } message: { 93 Text("This removes every game and clears the sync state on this device. Games on other devices and in iCloud are not affected.") 94 } 95 } 96 } 97 98 Section { 99 NavigationLink("About") { 100 AboutView() 101 } 102 } footer: { 103 Text("Made in Tokyo from natural ones and zeros") 104 .frame(maxWidth: .infinity) 105 .multilineTextAlignment(.center) 106 .padding(.top, 24) 107 .onTapGesture { 108 easterEggTaps += 1 109 if easterEggTaps >= 4 { 110 debugMode.toggle() 111 easterEggTaps = 0 112 } 113 } 114 } 115 } 116 .navigationTitle("Settings") 117 .navigationBarTitleDisplayMode(.inline) 118 .toolbar { 119 ToolbarItem(placement: .cancellationAction) { 120 Button { 121 dismiss() 122 } label: { 123 Image(systemName: "xmark") 124 } 125 .accessibilityLabel("Cancel") 126 } 127 } 128 .sheet(isPresented: $showingNYTLogin) { 129 NYTLoginView() 130 } 131 .onAppear { 132 if externalSource == nil, nytAuth.canAttemptNYTFetch { 133 externalSource = .newYorkTimes 134 } 135 } 136 } 137 } 138 139 // MARK: - Subviews 140 141 private enum ExternalSource: String, CaseIterable, Identifiable { 142 case newYorkTimes 143 144 var id: String { rawValue } 145 146 var title: String { 147 switch self { 148 case .newYorkTimes: "New York Times" 149 } 150 } 151 } 152 153 @ViewBuilder 154 private var signedInView: some View { 155 if let email = nytAuth.signedInEmail { 156 LabeledContent("E-mail", value: email) 157 } else { 158 LabeledContent("Status", value: "Signed in") 159 } 160 Button("Sign Out", role: .destructive) { 161 nytAuth.signOut() 162 } 163 } 164 165 @ViewBuilder 166 private var signInView: some View { 167 if let errorMessage = nytAuth.errorMessage { 168 Text(errorMessage) 169 .foregroundStyle(.red) 170 .font(.caption) 171 } 172 Button("Sign In with NYT") { 173 showingNYTLogin = true 174 } 175 } 176 177 @ViewBuilder 178 private var unknownSessionView: some View { 179 if let message = nytAuth.sessionStatusMessage { 180 Text(message) 181 .foregroundStyle(.secondary) 182 .font(.caption) 183 } 184 Button { 185 nytAuth.loadStoredSession() 186 } label: { 187 Label("Try Again", systemImage: "arrow.clockwise") 188 } 189 } 190 } 191 192 private struct ProfileNameEditView: View { 193 @Environment(PlayerPreferences.self) private var preferences 194 @State private var nameDraft = "" 195 @FocusState private var isNameFocused: Bool 196 197 var body: some View { 198 Form { 199 Section { 200 HStack { 201 TextField("Name", text: $nameDraft) 202 .textInputAutocapitalization(.never) 203 .autocorrectionDisabled() 204 .focused($isNameFocused) 205 .onSubmit(commitName) 206 207 if !nameDraft.isEmpty { 208 Button { 209 nameDraft = "" 210 isNameFocused = true 211 } label: { 212 Image(systemName: "xmark.circle.fill") 213 .foregroundStyle(.secondary) 214 } 215 .buttonStyle(.plain) 216 .accessibilityLabel("Clear Name") 217 } 218 } 219 } footer: { 220 Text("This is the name other players will see.") 221 } 222 } 223 .navigationTitle("Name") 224 .navigationBarTitleDisplayMode(.inline) 225 .onAppear { 226 nameDraft = preferences.name 227 } 228 .onChange(of: isNameFocused) { _, isFocused in 229 if !isFocused { 230 commitName() 231 } 232 } 233 .onDisappear { 234 commitName() 235 } 236 } 237 238 private var trimmedName: String { 239 nameDraft.trimmingCharacters(in: .whitespacesAndNewlines) 240 } 241 242 private var canCommitName: Bool { 243 !trimmedName.isEmpty && trimmedName != preferences.name 244 } 245 246 private func commitName() { 247 guard canCommitName else { return } 248 preferences.name = trimmedName 249 nameDraft = trimmedName 250 } 251 }