krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
remove-splash-highlighter: 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}