crossmate

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

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 }