SettingsView.swift (10657B)
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 32 NavigationLink { 33 ProfileColorEditView() 34 } label: { 35 HStack { 36 Text("Colour") 37 Spacer() 38 colorSwatch(preferences.color) 39 } 40 } 41 } header: { 42 Text("Profile") 43 } 44 45 Section("External Source") { 46 Picker("Publisher", selection: $externalSource) { 47 Text("None").tag(nil as ExternalSource?) 48 ForEach(ExternalSource.allCases) { source in 49 Text(source.title).tag(source as ExternalSource?) 50 } 51 } 52 .pickerStyle(.menu) 53 54 switch externalSource { 55 case nil: 56 EmptyView() 57 case .newYorkTimes: 58 switch nytAuth.sessionState { 59 case .signedIn: 60 signedInView 61 case .signedOut: 62 signInView 63 case .unknown: 64 unknownSessionView 65 } 66 } 67 } 68 69 Section { 70 Toggle("Nudges", isOn: $preferences.notifiesNudges) 71 Toggle("Pauses", isOn: $preferences.notifiesPauses) 72 Toggle("Invitations", isOn: $preferences.notifiesInvitations) 73 Toggle("Joins", isOn: $preferences.notifiesJoins) 74 Toggle("Completions", isOn: $preferences.notifiesCompletions) 75 } header: { 76 Text("Notifications") 77 } footer: { 78 Text("Receive notifications when friends nudge you, pause playing after making changes, invite you to play, join one of your puzzles or finish a puzzle. These settings apply only to this device.") 79 } 80 81 if debugMode { 82 Section("Debugging") { 83 Toggle("Enable iCloud Sync", isOn: $preferences.isICloudSyncEnabled) 84 85 NavigationLink("Diagnostics Log") { 86 DiagnosticsView() 87 } 88 NavigationLink("Record Editor") { 89 RecordEditorView() 90 } 91 92 Button("Reset Database", role: .destructive) { 93 showResetConfirmation = true 94 } 95 .alert( 96 "Delete all puzzles?", 97 isPresented: $showResetConfirmation 98 ) { 99 Button("Delete All Puzzles", role: .destructive) { 100 Task { await resetDatabase?() } 101 } 102 Button("Cancel", role: .cancel) {} 103 } message: { 104 Text("This removes every puzzle and clears the sync state on this device. Puzzles on other devices and in iCloud are not affected.") 105 } 106 } 107 } 108 109 Section { 110 NavigationLink("Tips") { 111 TipsArchive() 112 } 113 } 114 115 Section { 116 NavigationLink("About") { 117 AboutView() 118 } 119 } footer: { 120 Text("Made in Tokyo from natural ones and zeros") 121 .frame(maxWidth: .infinity) 122 .multilineTextAlignment(.center) 123 .padding(.top, 24) 124 .onTapGesture { 125 easterEggTaps += 1 126 if easterEggTaps >= 4 { 127 debugMode.toggle() 128 easterEggTaps = 0 129 } 130 } 131 } 132 } 133 .navigationTitle("Settings") 134 .navigationBarTitleDisplayMode(.inline) 135 .toolbar { 136 ToolbarItem(placement: .cancellationAction) { 137 Button { 138 dismiss() 139 } label: { 140 Image(systemName: "xmark") 141 } 142 .accessibilityLabel("Cancel") 143 } 144 } 145 .sheet(isPresented: $showingNYTLogin) { 146 NYTLoginView() 147 } 148 .onAppear { 149 if externalSource == nil, nytAuth.canAttemptNYTFetch { 150 externalSource = .newYorkTimes 151 } 152 } 153 } 154 } 155 156 // MARK: - Subviews 157 158 private func colorSwatch(_ color: PlayerColor) -> some View { 159 Circle() 160 .fill(color.tint) 161 .frame(width: 20, height: 20) 162 } 163 164 private enum ExternalSource: String, CaseIterable, Identifiable { 165 case newYorkTimes 166 167 var id: String { rawValue } 168 169 var title: String { 170 switch self { 171 case .newYorkTimes: "New York Times" 172 } 173 } 174 } 175 176 @ViewBuilder 177 private var signedInView: some View { 178 if let email = nytAuth.signedInEmail { 179 LabeledContent("E-mail", value: email) 180 } else { 181 LabeledContent("Status", value: "Signed in") 182 } 183 Button("Sign Out", role: .destructive) { 184 nytAuth.signOut() 185 } 186 } 187 188 @ViewBuilder 189 private var signInView: some View { 190 if let errorMessage = nytAuth.errorMessage { 191 Text(errorMessage) 192 .foregroundStyle(.red) 193 .font(.caption) 194 } 195 Button("Sign In with NYT") { 196 showingNYTLogin = true 197 } 198 } 199 200 @ViewBuilder 201 private var unknownSessionView: some View { 202 if let message = nytAuth.sessionStatusMessage { 203 Text(message) 204 .foregroundStyle(.secondary) 205 .font(.caption) 206 } 207 Button { 208 nytAuth.loadStoredSession() 209 } label: { 210 Label("Try Again", systemImage: "arrow.clockwise") 211 } 212 } 213 } 214 215 private struct ProfileColorEditView: View { 216 @Environment(PlayerPreferences.self) private var preferences 217 @Environment(\.dismiss) private var dismiss 218 219 var body: some View { 220 @Bindable var preferences = preferences 221 Form { 222 Section { 223 Picker("Colour", selection: $preferences.color) { 224 ForEach(PlayerColor.palette) { color in 225 Label { 226 Text(color.name) 227 } icon: { 228 Circle() 229 .fill(color.tint) 230 .frame(width: 20, height: 20) 231 } 232 .tag(color) 233 } 234 } 235 .pickerStyle(.inline) 236 .labelsHidden() 237 } footer: { 238 Text("This is the colour other players will see for your letters.") 239 } 240 } 241 .navigationTitle("Colour") 242 .navigationBarTitleDisplayMode(.inline) 243 // Mimic the native navigation-link picker, which pops back as soon as a 244 // colour is chosen. 245 .onChange(of: preferences.color) { 246 dismiss() 247 } 248 } 249 } 250 251 private struct ProfileNameEditView: View { 252 @Environment(PlayerPreferences.self) private var preferences 253 @State private var nameDraft = "" 254 @FocusState private var isNameFocused: Bool 255 256 var body: some View { 257 Form { 258 Section { 259 HStack { 260 TextField("Name", text: $nameDraft) 261 .textInputAutocapitalization(.never) 262 .autocorrectionDisabled() 263 .focused($isNameFocused) 264 .onSubmit(commitName) 265 266 if !nameDraft.isEmpty { 267 Button { 268 nameDraft = "" 269 isNameFocused = true 270 } label: { 271 Image(systemName: "xmark.circle.fill") 272 .foregroundStyle(.secondary) 273 } 274 .buttonStyle(.plain) 275 .accessibilityLabel("Clear Name") 276 } 277 } 278 } footer: { 279 Text("This is the name other players will see.") 280 } 281 } 282 .navigationTitle("Name") 283 .navigationBarTitleDisplayMode(.inline) 284 .onAppear { 285 nameDraft = preferences.name 286 } 287 .onChange(of: isNameFocused) { _, isFocused in 288 if !isFocused { 289 commitName() 290 } 291 } 292 .onDisappear { 293 commitName() 294 } 295 } 296 297 private var trimmedName: String { 298 nameDraft.trimmingCharacters(in: .whitespacesAndNewlines) 299 } 300 301 private var canCommitName: Bool { 302 !trimmedName.isEmpty && trimmedName != preferences.name 303 } 304 305 private func commitName() { 306 guard canCommitName else { return } 307 preferences.name = trimmedName 308 nameDraft = trimmedName 309 } 310 }