crossmate

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

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 }