SettingsView.swift (14404B)
1 import CoreData 2 import SwiftUI 3 4 struct SettingsView: View { 5 @Environment(NYTAuthService.self) private var nytAuth 6 @Environment(PlayerPreferences.self) private var preferences 7 @Environment(\.dismiss) private var dismiss 8 @Environment(\.appActions) private var appActions 9 10 @AppStorage("debugMode") private var debugMode = false 11 @State private var showingNYTLogin = false 12 @State private var showResetConfirmation = false 13 @State private var externalSource: ExternalSource? 14 @State private var easterEggTaps = 0 15 16 var body: some View { 17 @Bindable var preferences = preferences 18 NavigationStack { 19 Form { 20 Section { 21 NavigationLink { 22 ProfileNameEditView() 23 } label: { 24 HStack { 25 Text("Name") 26 Spacer() 27 Text(preferences.name) 28 .foregroundStyle(.secondary) 29 .lineLimit(1) 30 } 31 } 32 33 NavigationLink { 34 ProfileColorEditView() 35 } label: { 36 HStack { 37 Text("Colour") 38 Spacer() 39 colorSwatch(preferences.color) 40 } 41 } 42 } header: { 43 Text("Profile") 44 } 45 46 Section("External Source") { 47 Picker("Publisher", selection: $externalSource) { 48 Text("None").tag(nil as ExternalSource?) 49 ForEach(ExternalSource.allCases) { source in 50 Text(source.title).tag(source as ExternalSource?) 51 } 52 } 53 .pickerStyle(.menu) 54 55 switch externalSource { 56 case nil: 57 EmptyView() 58 case .newYorkTimes: 59 switch nytAuth.sessionState { 60 case .signedIn: 61 signedInView 62 case .signedOut: 63 signInView 64 case .unknown: 65 unknownSessionView 66 } 67 } 68 } 69 70 MovementSettingsSection(preferences: preferences) 71 72 Section { 73 Toggle("Nudges", isOn: $preferences.notifiesNudges) 74 Toggle("Pauses", isOn: $preferences.notifiesPauses) 75 Toggle("Invitations", isOn: $preferences.notifiesInvitations) 76 Toggle("Joins", isOn: $preferences.notifiesJoins) 77 Toggle("Completions", isOn: $preferences.notifiesCompletions) 78 } header: { 79 Text("Notifications") 80 } footer: { 81 Text("Notification settings apply only to this device.") 82 } 83 84 Section("Sharing") { 85 NavigationLink("Blocked Users") { 86 BlockedUsersView() 87 } 88 } 89 90 if debugMode { 91 Section("Debugging") { 92 Toggle("Enable iCloud Sync", isOn: $preferences.isICloudSyncEnabled) 93 94 NavigationLink("Diagnostics Log") { 95 DiagnosticsView() 96 } 97 98 Button("Reset Database", role: .destructive) { 99 showResetConfirmation = true 100 } 101 .alert( 102 "Delete All Puzzles?", 103 isPresented: $showResetConfirmation 104 ) { 105 Button("Delete All Puzzles", role: .destructive) { 106 Task { await appActions?.resetDatabase() } 107 } 108 Button("Cancel", role: .cancel) {} 109 } message: { 110 Text("This removes every puzzle and clears the sync state on this device. Puzzles on other devices and in iCloud are not affected.") 111 } 112 } 113 } 114 115 Section { 116 NavigationLink("Tips") { 117 TipsArchive() 118 } 119 NavigationLink("About") { 120 AboutView() 121 } 122 } footer: { 123 Text("Made in Tokyo from natural ones and zeros") 124 .frame(maxWidth: .infinity) 125 .multilineTextAlignment(.center) 126 .padding(.vertical, 24) 127 .onTapGesture { 128 easterEggTaps += 1 129 if easterEggTaps >= 4 { 130 debugMode.toggle() 131 easterEggTaps = 0 132 } 133 } 134 } 135 } 136 .navigationTitle("Settings") 137 .navigationBarTitleDisplayMode(.inline) 138 .toolbar { 139 ToolbarItem(placement: .cancellationAction) { 140 Button { 141 dismiss() 142 } label: { 143 Image(systemName: "xmark") 144 } 145 .accessibilityLabel("Cancel") 146 } 147 } 148 .sheet(isPresented: $showingNYTLogin) { 149 NYTLoginView() 150 } 151 .onAppear { 152 if externalSource == nil, nytAuth.canAttemptNYTFetch { 153 externalSource = .newYorkTimes 154 } 155 } 156 } 157 } 158 159 // MARK: - Subviews 160 161 private func colorSwatch(_ color: PlayerColor) -> some View { 162 Circle() 163 .fill(color.tint) 164 .frame(width: 20, height: 20) 165 } 166 167 private enum ExternalSource: String, CaseIterable, Identifiable { 168 case newYorkTimes 169 170 var id: String { rawValue } 171 172 var title: String { 173 switch self { 174 case .newYorkTimes: "New York Times" 175 } 176 } 177 } 178 179 @ViewBuilder 180 private var signedInView: some View { 181 if let email = nytAuth.signedInEmail { 182 LabeledContent("E-mail", value: email) 183 } else { 184 LabeledContent("Status", value: "Signed in") 185 } 186 Button("Sign Out", role: .destructive) { 187 nytAuth.signOut() 188 } 189 } 190 191 @ViewBuilder 192 private var signInView: some View { 193 if let errorMessage = nytAuth.errorMessage { 194 Text(errorMessage) 195 .foregroundStyle(.red) 196 .font(.caption) 197 } 198 Button("Sign In with NYT") { 199 showingNYTLogin = true 200 } 201 } 202 203 @ViewBuilder 204 private var unknownSessionView: some View { 205 if let message = nytAuth.sessionStatusMessage { 206 Text(message) 207 .foregroundStyle(.secondary) 208 .font(.caption) 209 } 210 Button { 211 nytAuth.loadStoredSession() 212 } label: { 213 Label("Try Again", systemImage: "arrow.clockwise") 214 } 215 } 216 } 217 218 private struct MovementSettingsSection: View { 219 @Bindable var preferences: PlayerPreferences 220 221 var body: some View { 222 Section { 223 Toggle("Skip Filled Squares", isOn: $preferences.skipsFilledCells) 224 VStack(alignment: .leading, spacing: 2) { 225 HStack(alignment: .firstTextBaseline, spacing: 12) { 226 Text("After Last Fill") 227 .frame(maxWidth: .infinity, alignment: .leading) 228 .fixedSize(horizontal: false, vertical: true) 229 230 Picker("After Last Fill", selection: $preferences.answerEndMovement) { 231 Text("Do Nothing") 232 .tag(PlayerPreferences.AnswerEndMovement.doNothing) 233 Text("Wrap in Current Answer") 234 .tag(PlayerPreferences.AnswerEndMovement.wrapInCurrentAnswer) 235 Text("Move to Next Answer") 236 .tag(PlayerPreferences.AnswerEndMovement.moveToNextAnswer) 237 } 238 .labelsHidden() 239 .pickerStyle(.menu) 240 .fixedSize(horizontal: true, vertical: false) 241 } 242 243 Text("Sets behaviour after filling in the last square in an answer.") 244 .font(.footnote) 245 .foregroundStyle(.secondary) 246 } 247 } header: { 248 Text("Movement") 249 } 250 } 251 } 252 253 private struct BlockedUsersView: View { 254 @Environment(\.appActions) private var appActions 255 256 @FetchRequest( 257 sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)], 258 predicate: NSPredicate(format: "isBlocked == YES"), 259 animation: .default 260 ) 261 private var blockedFriends: FetchedResults<FriendEntity> 262 263 @State private var unblockTarget: FriendEntity? 264 265 var body: some View { 266 List { 267 ForEach(blockedFriends, id: \.authorID) { friend in 268 blockedFriendRow(for: friend) 269 } 270 } 271 .overlay { 272 if blockedFriends.isEmpty { 273 ContentUnavailableView { 274 Label("No Blocked Users", systemImage: "person.crop.circle.badge.xmark") 275 } description: { 276 Text("Blocked crossmates will appear here.") 277 } 278 } 279 } 280 .navigationTitle("Blocked Users") 281 .navigationBarTitleDisplayMode(.inline) 282 .alert("Unblock This Player?", isPresented: .init( 283 get: { unblockTarget != nil }, 284 set: { if !$0 { unblockTarget = nil } } 285 )) { 286 Button("Unblock") { 287 guard let authorID = unblockTarget?.authorID, !authorID.isEmpty else { return } 288 Task { await appActions?.unblockFriend(authorID: authorID) } 289 } 290 Button("Cancel", role: .cancel) {} 291 } message: { 292 let name = unblockTarget?.resolvedDisplayName ?? "this player" 293 Text("\(name) will be able to invite you again, and any puzzles hidden when you blocked them will return to your list.") 294 } 295 } 296 297 @ViewBuilder 298 private func blockedFriendRow(for friend: FriendEntity) -> some View { 299 let authorID = friend.authorID ?? "" 300 301 HStack { 302 FriendAvatarView(authorID: authorID) 303 .padding(.trailing, 8) 304 Text(friend.resolvedDisplayName) 305 .foregroundStyle(.primary) 306 Spacer() 307 Button("Unblock") { 308 unblockTarget = friend 309 } 310 .disabled(authorID.isEmpty) 311 } 312 .swipeActions(edge: .trailing) { 313 Button("Unblock") { unblockTarget = friend } 314 .tint(.blue) 315 } 316 } 317 } 318 319 private struct ProfileColorEditView: View { 320 @Environment(PlayerPreferences.self) private var preferences 321 @Environment(\.dismiss) private var dismiss 322 323 var body: some View { 324 @Bindable var preferences = preferences 325 Form { 326 Section { 327 Picker("Colour", selection: $preferences.color) { 328 ForEach(PlayerColor.palette) { color in 329 Label { 330 Text(color.name) 331 } icon: { 332 Circle() 333 .fill(color.tint) 334 .frame(width: 20, height: 20) 335 } 336 .tag(color) 337 } 338 } 339 .pickerStyle(.inline) 340 .labelsHidden() 341 } footer: { 342 Text("This is the colour other players will see for your letters.") 343 } 344 } 345 .navigationTitle("Colour") 346 .navigationBarTitleDisplayMode(.inline) 347 // Mimic the native navigation-link picker, which pops back as soon as a 348 // colour is chosen. 349 .onChange(of: preferences.color) { 350 dismiss() 351 } 352 } 353 } 354 355 private struct ProfileNameEditView: View { 356 @Environment(PlayerPreferences.self) private var preferences 357 @State private var nameDraft = "" 358 @FocusState private var isNameFocused: Bool 359 360 var body: some View { 361 Form { 362 Section { 363 HStack { 364 TextField("Name", text: $nameDraft) 365 .textInputAutocapitalization(.never) 366 .autocorrectionDisabled() 367 .focused($isNameFocused) 368 .onSubmit(commitName) 369 370 if !nameDraft.isEmpty { 371 Button { 372 nameDraft = "" 373 isNameFocused = true 374 } label: { 375 Image(systemName: "xmark.circle.fill") 376 .foregroundStyle(.secondary) 377 } 378 .buttonStyle(.plain) 379 .accessibilityLabel("Clear Name") 380 } 381 } 382 } footer: { 383 Text("This is the name other players will see.") 384 } 385 } 386 .navigationTitle("Name") 387 .navigationBarTitleDisplayMode(.inline) 388 .onAppear { 389 nameDraft = preferences.name 390 } 391 .onChange(of: isNameFocused) { _, isFocused in 392 if !isFocused { 393 commitName() 394 } 395 } 396 .onDisappear { 397 commitName() 398 } 399 } 400 401 private var trimmedName: String { 402 nameDraft.trimmingCharacters(in: .whitespacesAndNewlines) 403 } 404 405 private var canCommitName: Bool { 406 !trimmedName.isEmpty && trimmedName != preferences.name 407 } 408 409 private func commitName() { 410 guard canCommitName else { return } 411 preferences.name = trimmedName 412 nameDraft = trimmedName 413 } 414 }