crossmate

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

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 }