crossmate

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

SettingsView.swift (8687B)


      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                 } header: {
     32                     Text("Profile")
     33                 }
     34 
     35                 Section("External Source") {
     36                     Picker("Publisher", selection: $externalSource) {
     37                         Text("None").tag(nil as ExternalSource?)
     38                         ForEach(ExternalSource.allCases) { source in
     39                             Text(source.title).tag(source as ExternalSource?)
     40                         }
     41                     }
     42                     .pickerStyle(.menu)
     43 
     44                     switch externalSource {
     45                     case nil:
     46                         EmptyView()
     47                     case .newYorkTimes:
     48                         switch nytAuth.sessionState {
     49                         case .signedIn:
     50                             signedInView
     51                         case .signedOut:
     52                             signInView
     53                         case .unknown:
     54                             unknownSessionView
     55                         }
     56                     }
     57                 }
     58 
     59                 Section {
     60                     Toggle("Started Solving", isOn: $preferences.notifiesSessionBegin)
     61                     Toggle("Stopped Solving", isOn: $preferences.notifiesSessionEnd)
     62                     Toggle("Puzzle Invited", isOn: $preferences.notifiesInvites)
     63                     Toggle("Puzzle Finished", isOn: $preferences.notifiesPuzzleFinished)
     64                 } header: {
     65                     Text("Notifications")
     66                 } footer: {
     67                     Text("Receive notifications when friends start solving, stop solving, invite you to or finish a shared puzzle. These settings apply only to this device.")
     68                 }
     69 
     70                 if debugMode {
     71                     Section("Debugging") {
     72                         Toggle("Enable iCloud Sync", isOn: $preferences.isICloudSyncEnabled)
     73 
     74                         NavigationLink("Diagnostics Log") {
     75                             DiagnosticsView()
     76                         }
     77                         NavigationLink("Record Editor") {
     78                             RecordEditorView()
     79                         }
     80 
     81                         Button("Reset Database", role: .destructive) {
     82                             showResetConfirmation = true
     83                         }
     84                         .alert(
     85                             "Delete all games?",
     86                             isPresented: $showResetConfirmation
     87                         ) {
     88                             Button("Delete All Games", role: .destructive) {
     89                                 Task { await resetDatabase?() }
     90                             }
     91                             Button("Cancel", role: .cancel) {}
     92                         } message: {
     93                             Text("This removes every game and clears the sync state on this device. Games on other devices and in iCloud are not affected.")
     94                         }
     95                     }
     96                 }
     97 
     98                 Section {
     99                     NavigationLink("About") {
    100                         AboutView()
    101                     }
    102                 } footer: {
    103                     Text("Made in Tokyo from natural ones and zeros")
    104                         .frame(maxWidth: .infinity)
    105                         .multilineTextAlignment(.center)
    106                         .padding(.top, 24)
    107                         .onTapGesture {
    108                             easterEggTaps += 1
    109                             if easterEggTaps >= 4 {
    110                                 debugMode.toggle()
    111                                 easterEggTaps = 0
    112                             }
    113                         }
    114                 }
    115             }
    116             .navigationTitle("Settings")
    117             .navigationBarTitleDisplayMode(.inline)
    118             .toolbar {
    119                 ToolbarItem(placement: .cancellationAction) {
    120                     Button {
    121                         dismiss()
    122                     } label: {
    123                         Image(systemName: "xmark")
    124                     }
    125                     .accessibilityLabel("Cancel")
    126                 }
    127             }
    128             .sheet(isPresented: $showingNYTLogin) {
    129                 NYTLoginView()
    130             }
    131             .onAppear {
    132                 if externalSource == nil, nytAuth.canAttemptNYTFetch {
    133                     externalSource = .newYorkTimes
    134                 }
    135             }
    136         }
    137     }
    138 
    139     // MARK: - Subviews
    140 
    141     private enum ExternalSource: String, CaseIterable, Identifiable {
    142         case newYorkTimes
    143 
    144         var id: String { rawValue }
    145 
    146         var title: String {
    147             switch self {
    148             case .newYorkTimes: "New York Times"
    149             }
    150         }
    151     }
    152 
    153     @ViewBuilder
    154     private var signedInView: some View {
    155         if let email = nytAuth.signedInEmail {
    156             LabeledContent("E-mail", value: email)
    157         } else {
    158             LabeledContent("Status", value: "Signed in")
    159         }
    160         Button("Sign Out", role: .destructive) {
    161             nytAuth.signOut()
    162         }
    163     }
    164 
    165     @ViewBuilder
    166     private var signInView: some View {
    167         if let errorMessage = nytAuth.errorMessage {
    168             Text(errorMessage)
    169                 .foregroundStyle(.red)
    170                 .font(.caption)
    171         }
    172         Button("Sign In with NYT") {
    173             showingNYTLogin = true
    174         }
    175     }
    176 
    177     @ViewBuilder
    178     private var unknownSessionView: some View {
    179         if let message = nytAuth.sessionStatusMessage {
    180             Text(message)
    181                 .foregroundStyle(.secondary)
    182                 .font(.caption)
    183         }
    184         Button {
    185             nytAuth.loadStoredSession()
    186         } label: {
    187             Label("Try Again", systemImage: "arrow.clockwise")
    188         }
    189     }
    190 }
    191 
    192 private struct ProfileNameEditView: View {
    193     @Environment(PlayerPreferences.self) private var preferences
    194     @State private var nameDraft = ""
    195     @FocusState private var isNameFocused: Bool
    196 
    197     var body: some View {
    198         Form {
    199             Section {
    200                 HStack {
    201                     TextField("Name", text: $nameDraft)
    202                         .textInputAutocapitalization(.never)
    203                         .autocorrectionDisabled()
    204                         .focused($isNameFocused)
    205                         .onSubmit(commitName)
    206 
    207                     if !nameDraft.isEmpty {
    208                         Button {
    209                             nameDraft = ""
    210                             isNameFocused = true
    211                         } label: {
    212                             Image(systemName: "xmark.circle.fill")
    213                                 .foregroundStyle(.secondary)
    214                         }
    215                         .buttonStyle(.plain)
    216                         .accessibilityLabel("Clear Name")
    217                     }
    218                 }
    219             } footer: {
    220                 Text("This is the name other players will see.")
    221             }
    222         }
    223         .navigationTitle("Name")
    224         .navigationBarTitleDisplayMode(.inline)
    225         .onAppear {
    226             nameDraft = preferences.name
    227         }
    228         .onChange(of: isNameFocused) { _, isFocused in
    229             if !isFocused {
    230                 commitName()
    231             }
    232         }
    233         .onDisappear {
    234             commitName()
    235         }
    236     }
    237 
    238     private var trimmedName: String {
    239         nameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
    240     }
    241 
    242     private var canCommitName: Bool {
    243         !trimmedName.isEmpty && trimmedName != preferences.name
    244     }
    245 
    246     private func commitName() {
    247         guard canCommitName else { return }
    248         preferences.name = trimmedName
    249         nameDraft = trimmedName
    250     }
    251 }