krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.4: 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 @State private var pendingDestructiveAction: SettingsDestructiveAction?
10 @State private var showAccountSwitcher = false
11
12 var body: some View {
13 Form {
14 appearanceSection()
15 behaviorSection()
16 authenticationSection()
17 aboutSection()
18 }
19 .themedList()
20 .navigationTitle("Settings")
21 .sheet(isPresented: $showAccountSwitcher) {
22 AccountSwitcherView()
23 }
24 .alert(
25 pendingDestructiveAction?.title ?? "",
26 isPresented: Binding(
27 get: { pendingDestructiveAction != nil },
28 set: { isPresented in
29 if !isPresented {
30 pendingDestructiveAction = nil
31 }
32 }
33 )
34 ) {
35 Button("Cancel", role: .cancel) {
36 /* Dismiss only; destructive action is separate. */
37 }
38 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
39 guard let action = pendingDestructiveAction else { return }
40 pendingDestructiveAction = nil
41 Task {
42 switch action {
43 case .resetAppData:
44 await appState.resetAppData()
45 case .signOut:
46 await appState.signOut()
47 }
48 }
49 }
50 } message: {
51 if let pendingDestructiveAction {
52 Text(pendingDestructiveAction.message)
53 }
54 }
55 }
56
57 @ViewBuilder
58 private func appearanceSection() -> some View {
59 Section {
60 Picker("Theme", selection: $appTheme) {
61 ForEach(AppTheme.allCases) { theme in
62 Text(theme.label).tag(theme)
63 }
64 }
65 .themedRow()
66 Picker("Density", selection: $displayDensity) {
67 ForEach(DisplayDensity.allCases) { density in
68 Text(density.label).tag(density)
69 }
70 }
71 .themedRow()
72 } header: {
73 Text("Appearance")
74 } footer: {
75 Text("Compact density reduces spacing throughout the app.")
76 }
77 }
78
79 @ViewBuilder
80 private func behaviorSection() -> some View {
81 Section {
82 Toggle("Swipe actions", isOn: $swipeActionsEnabled)
83 .themedRow()
84 Toggle("Contribution graphs", isOn: $contributionGraphsEnabled)
85 .onChange(of: contributionGraphsEnabled) { _, newValue in
86 ContributionWidgetContextStore.setEnabled(newValue)
87 }
88 .themedRow()
89 } header: {
90 Text("Behavior")
91 } footer: {
92 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.")
93 }
94 }
95
96 @ViewBuilder
97 private func authenticationSection() -> some View {
98 Section {
99 HStack {
100 Image(systemName: "key.fill")
101 .foregroundStyle(.secondary)
102 VStack(alignment: .leading, spacing: 2) {
103 Text(appState.currentUser?.canonicalName ?? "No active account")
104 Text("\(appState.accounts.count) saved account\(appState.accounts.count == 1 ? "" : "s")")
105 .font(.caption)
106 .foregroundStyle(.secondary)
107 }
108 }
109 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
110 .themedRow()
111
112 Button {
113 showAccountSwitcher = true
114 } label: {
115 Label("Manage Accounts", systemImage: "person.2")
116 }
117 .themedRow()
118
119 HStack {
120 Image(systemName: "lock.shield")
121 .foregroundStyle(.secondary)
122 Text("Tokens are stored separately per account in the iOS keychain")
123 .font(.subheadline)
124 .foregroundStyle(.secondary)
125 }
126 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
127 .themedRow()
128
129 Button("Reset App Data", role: .destructive) {
130 pendingDestructiveAction = .resetAppData
131 }
132 .themedRow()
133
134 Button("Sign Out", role: .destructive) {
135 pendingDestructiveAction = .signOut
136 }
137 .themedRow()
138 } header: {
139 Text("Authentication")
140 } footer: {
141 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.")
142 }
143 }
144
145 @ViewBuilder
146 private func aboutSection() -> some View {
147 Section("App") {
148 NavigationLink {
149 AboutView()
150 } label: {
151 SwiftUI.Label("About Hutch", systemImage: "info.circle")
152 }
153 .themedRow()
154 }
155 }
156}
157
158func settingsBioAttributedString(_ markdown: String) -> AttributedString {
159 profileBioAttributedString(markdown)
160}
161
162private enum SettingsDestructiveAction {
163 case resetAppData
164 case signOut
165
166 var title: String {
167 switch self {
168 case .resetAppData:
169 "Reset App Data?"
170 case .signOut:
171 "Sign Out?"
172 }
173 }
174
175 var confirmationLabel: String {
176 switch self {
177 case .resetAppData:
178 "Reset App Data"
179 case .signOut:
180 "Sign Out"
181 }
182 }
183
184 var message: String {
185 switch self {
186 case .resetAppData:
187 "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
188 case .signOut:
189 "This signs you out of Hutch and clears saved authentication state on this device."
190 }
191 }
192}
193
194private struct AboutView: View {
195 @Environment(AppState.self) private var appState
196 private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
197 ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
198 ?? "Hutch"
199 private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
200 ?? "Unknown"
201 private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
202 ?? "Unknown"
203 @State private var developerRevealCount = 0
204
205 private var developerToolsVisible: Bool {
206 appState.isDebugModeEnabled || developerRevealCount >= 5
207 }
208
209 private var developerRevealFooterText: String {
210 developerRevealCount >= 5
211 ? "Debug toggle unlocked. Scroll down to Developer to enable it."
212 : "Tap the build number 5 times to reveal the debug toggle."
213 }
214
215 var body: some View {
216 Form {
217 Section {
218 VStack(alignment: .leading, spacing: 6) {
219 Text(appName)
220 .font(.title2.weight(.semibold))
221 Text("A native SourceHut client for iOS.")
222 .font(.subheadline)
223 .foregroundStyle(.secondary)
224 }
225 .padding(.vertical, 4)
226 .themedRow()
227
228 LabeledContent("Version", value: version)
229 .onTapGesture {
230 developerRevealCount = min(developerRevealCount + 1, 5)
231 }
232 .themedRow()
233 LabeledContent("Build", value: build)
234 .onTapGesture {
235 developerRevealCount = min(developerRevealCount + 1, 5)
236 }
237 .themedRow()
238 } footer: {
239 Text(developerRevealFooterText)
240 }
241
242 Section("Links") {
243 Link(destination: URL(string: "https://sr.ht")!) {
244 SwiftUI.Label("SourceHut", systemImage: "link")
245 }
246 .themedRow()
247 Link(destination: URL(string: "https://man.sr.ht")!) {
248 SwiftUI.Label("SourceHut Manuals", systemImage: "book")
249 }
250 .themedRow()
251 Link(destination: URL(string: "https://sr.ht/~ccleberg/Hutch")!) {
252 SwiftUI.Label("Project Repository", systemImage: "folder")
253 }
254 .themedRow()
255 }
256
257 Section("Support") {
258 Link(destination: URL(string: "mailto:hello@cleberg.net")!) {
259 SwiftUI.Label("Email Support", systemImage: "envelope")
260 }
261 .themedRow()
262 }
263
264 Section("Privacy") {
265 Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
266 .font(.subheadline)
267 .foregroundStyle(.secondary)
268 .themedRow()
269
270 Link(destination: URL(string: "https://zerolabs.sh/hutch/privacy-policy/")!) {
271 SwiftUI.Label("Privacy Policy", systemImage: "hand.raised")
272 }
273 .themedRow()
274 }
275
276 Section("Acknowledgements") {
277 Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iOS.")
278 .font(.subheadline)
279 .foregroundStyle(.secondary)
280 .themedRow()
281 }
282
283 if developerToolsVisible {
284 Section {
285 Toggle("Debug Mode", isOn: Binding(
286 get: { appState.isDebugModeEnabled },
287 set: { appState.isDebugModeEnabled = $0 }
288 ))
289 .themedRow()
290
291 NavigationLink {
292 HomePrototypeView()
293 } label: {
294 SwiftUI.Label("Home Prototype", systemImage: "house")
295 }
296 .themedRow()
297
298 NavigationLink {
299 WorkPrototypeView()
300 } label: {
301 SwiftUI.Label("Work Prototype", systemImage: "tray.full")
302 }
303 .themedRow()
304 } header: {
305 Text("Developer")
306 } footer: {
307 Text("Shows raw API payloads and diagnostic details on builds and tickets screens. Home Prototype explores the dashboard structure, while Work Prototype evaluates the personal queue surface. This stays hidden until explicitly enabled.")
308 }
309 }
310 }
311 .themedList()
312 .navigationTitle("About")
313 .navigationBarTitleDisplayMode(.inline)
314 }
315}