krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.9.0: Hutch/Views/Settings/SettingsView.swift · raw

  1import SwiftUI
  2
  3struct SettingsView: View {
  4    @Environment(AppState.self) private var appState
  5    @AppStorage(AppStorageKeys.appTheme, store: .standard) private var appTheme: AppTheme = .system
  6    @AppStorage(AppStorageKeys.displayDensity, store: .standard) private var displayDensity: DisplayDensity = .standard
  7    @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
  8    @AppStorage(AppStorageKeys.contributionGraphsEnabled, store: .standard) private var contributionGraphsEnabled = true
  9    @AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
 10    private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
 11    @State private var pendingDestructiveAction: SettingsDestructiveAction?
 12    @State private var showAccountSwitcher = false
 13    @State private var preferences: NotificationPreferencesViewModel?
 14
 15    var body: some View {
 16        Form {
 17            appearanceSection()
 18            behaviorSection()
 19            emailSection()
 20            safariExtensionSection()
 21            authenticationSection()
 22        }
 23        .themedList()
 24        .navigationTitle("Settings")
 25        .task {
 26            let viewModel = preferences ?? NotificationPreferencesViewModel(client: appState.client)
 27            preferences = viewModel
 28            await viewModel.loadIfNeeded()
 29        }
 30        .sheet(isPresented: $showAccountSwitcher) {
 31            AccountSwitcherView()
 32        }
 33        .alert(
 34            pendingDestructiveAction?.title ?? "",
 35            isPresented: Binding(
 36                get: { pendingDestructiveAction != nil },
 37                set: { isPresented in
 38                    if !isPresented {
 39                        pendingDestructiveAction = nil
 40                    }
 41                }
 42            )
 43        ) {
 44            Button("Cancel", role: .cancel) {
 45                /* Dismiss only; destructive action is separate. */
 46            }
 47            Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
 48                guard let action = pendingDestructiveAction else { return }
 49                pendingDestructiveAction = nil
 50                Task {
 51                    switch action {
 52                    case .resetAppData:
 53                        await appState.resetAppData()
 54                    case .signOut:
 55                        await appState.signOut()
 56                    }
 57                }
 58            }
 59        } message: {
 60            if let pendingDestructiveAction {
 61                Text(pendingDestructiveAction.message)
 62            }
 63        }
 64    }
 65
 66    @ViewBuilder
 67    private func safariExtensionSection() -> some View {
 68        Section {
 69            NavigationLink {
 70                SafariExtensionHelpView()
 71            } label: {
 72                Label("Safari Extension", systemImage: "safari")
 73            }
 74            .themedRow()
 75        } header: {
 76            Text("Browser")
 77        } footer: {
 78            Text("Open supported SourceHut pages in Hutch from Safari without claiming sr.ht links system-wide.")
 79        }
 80    }
 81
 82    @ViewBuilder
 83    private func appearanceSection() -> some View {
 84        Section {
 85            Picker("Theme", selection: $appTheme) {
 86                ForEach(AppTheme.allCases) { theme in
 87                    Text(theme.label).tag(theme)
 88                }
 89            }
 90            .themedRow()
 91            Picker("Density", selection: $displayDensity) {
 92                ForEach(DisplayDensity.allCases) { density in
 93                    Text(density.label).tag(density)
 94                }
 95            }
 96            .themedRow()
 97        } header: {
 98            Text("Appearance")
 99        } footer: {
100            Text("Compact density reduces spacing throughout the app.")
101        }
102    }
103
104    @ViewBuilder
105    private func behaviorSection() -> some View {
106        Section {
107            Toggle("Swipe actions", isOn: $swipeActionsEnabled)
108                .themedRow()
109            Toggle("Contribution graphs", isOn: $contributionGraphsEnabled)
110                .onChange(of: contributionGraphsEnabled) { _, newValue in
111                    ContributionWidgetContextStore.setEnabled(newValue)
112                }
113                .themedRow()
114            Picker("Failed build window", selection: $failedBuildLookbackDays) {
115                ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in
116                    Text(failedBuildWindowLabel(days)).tag(days)
117                }
118            }
119            .themedRow()
120        } header: {
121            Text("Behavior")
122        } footer: {
123            Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes. Contribution graphs controls whether SourceHut activity heatmaps appear in lookup profiles. Failed build window controls how far back the Home tab counts failed builds.")
124        }
125    }
126
127    @ViewBuilder
128    private func emailSection() -> some View {
129        Section {
130            if let preferences {
131                Toggle(
132                    "Notify me about my own tickets",
133                    isOn: Binding(
134                        get: { preferences.notifySelf },
135                        set: { newValue in
136                            Task { await preferences.setNotifySelf(newValue) }
137                        }
138                    )
139                )
140                .disabled(preferences.isLoading || preferences.isSavingNotifySelf)
141                .themedRow()
142
143                Toggle(
144                    "Copy me on my own list mail",
145                    isOn: Binding(
146                        get: { preferences.copySelf },
147                        set: { newValue in
148                            Task { await preferences.setCopySelf(newValue) }
149                        }
150                    )
151                )
152                .disabled(preferences.isLoading || preferences.isSavingCopySelf)
153                .themedRow()
154
155                if let error = preferences.error {
156                    Text(error)
157                        .font(.caption)
158                        .foregroundStyle(.red)
159                        .themedRow()
160                }
161            } else {
162                ProgressView()
163                    .themedRow()
164            }
165        } header: {
166            Text("Email")
167        } footer: {
168            Text("These are stored on SourceHut and apply everywhere, not just in Hutch. The first controls whether todo.sr.ht emails you about your own ticket activity; the second whether lists.sr.ht copies you on mail you send to a list.")
169        }
170    }
171
172    @ViewBuilder
173    private func authenticationSection() -> some View {
174        Section {
175            HStack {
176                Image(systemName: "key.fill")
177                    .foregroundStyle(.secondary)
178                VStack(alignment: .leading, spacing: 2) {
179                    Text(appState.currentUser?.canonicalName ?? "No active account")
180                    Text("\(appState.accounts.count) saved account\(appState.accounts.count == 1 ? "" : "s")")
181                        .font(.caption)
182                        .foregroundStyle(.secondary)
183                }
184            }
185            .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
186            .themedRow()
187
188            Button {
189                showAccountSwitcher = true
190            } label: {
191                Label("Manage Accounts", systemImage: "person.2")
192            }
193            .themedRow()
194
195            HStack {
196                Image(systemName: "lock.shield")
197                    .foregroundStyle(.secondary)
198                Text("Tokens are stored separately per account in the iOS keychain")
199                    .font(.subheadline)
200                    .foregroundStyle(.secondary)
201            }
202            .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
203            .themedRow()
204
205            Button {
206                Task { await appState.client.clearPersistentCache() }
207            } label: {
208                Label("Clear Cache", systemImage: "externaldrive.badge.xmark")
209            }
210            .themedRow()
211
212            Button("Reset App Data", role: .destructive) {
213                pendingDestructiveAction = .resetAppData
214            }
215            .themedRow()
216
217            Button("Sign Out", role: .destructive) {
218                pendingDestructiveAction = .signOut
219            }
220            .themedRow()
221        } header: {
222            Text("Authentication")
223        } footer: {
224            Text("Account switching keeps local caches and saved state isolated per account. Sign Out removes all saved accounts from this device. Reset App Data also clears local settings, cached responses, cookies, and embedded web data.")
225        }
226    }
227
228}
229
230private func failedBuildWindowLabel(_ days: Int) -> String {
231    if days == 1 {
232        return "Today only"
233    }
234    return "Last \(days) days"
235}
236
237func settingsBioAttributedString(_ markdown: String) -> AttributedString {
238    profileBioAttributedString(markdown)
239}
240
241private enum SettingsDestructiveAction {
242    case resetAppData
243    case signOut
244
245    var title: String {
246        switch self {
247        case .resetAppData:
248            "Reset App Data?"
249        case .signOut:
250            "Sign Out?"
251        }
252    }
253
254    var confirmationLabel: String {
255        switch self {
256        case .resetAppData:
257            "Reset App Data"
258        case .signOut:
259            "Sign Out"
260        }
261    }
262
263    var message: String {
264        switch self {
265        case .resetAppData:
266            "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
267        case .signOut:
268            "This signs you out of Hutch and clears saved authentication state on this device."
269        }
270    }
271}