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