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}