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