krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.4.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
14 var body: some View {
15 Form {
16 appearanceSection()
17 behaviorSection()
18 safariExtensionSection()
19 authenticationSection()
20 }
21 .themedList()
22 .navigationTitle("Settings")
23 .sheet(isPresented: $showAccountSwitcher) {
24 AccountSwitcherView()
25 }
26 .alert(
27 pendingDestructiveAction?.title ?? "",
28 isPresented: Binding(
29 get: { pendingDestructiveAction != nil },
30 set: { isPresented in
31 if !isPresented {
32 pendingDestructiveAction = nil
33 }
34 }
35 )
36 ) {
37 Button("Cancel", role: .cancel) {
38 /* Dismiss only; destructive action is separate. */
39 }
40 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
41 guard let action = pendingDestructiveAction else { return }
42 pendingDestructiveAction = nil
43 Task {
44 switch action {
45 case .resetAppData:
46 await appState.resetAppData()
47 case .signOut:
48 await appState.signOut()
49 }
50 }
51 }
52 } message: {
53 if let pendingDestructiveAction {
54 Text(pendingDestructiveAction.message)
55 }
56 }
57 }
58
59 @ViewBuilder
60 private func safariExtensionSection() -> some View {
61 Section {
62 NavigationLink {
63 SafariExtensionHelpView()
64 } label: {
65 Label("Safari Extension", systemImage: "safari")
66 }
67 .themedRow()
68 } header: {
69 Text("Browser")
70 } footer: {
71 Text("Open supported SourceHut pages in Hutch from Safari without claiming sr.ht links system-wide.")
72 }
73 }
74
75 @ViewBuilder
76 private func appearanceSection() -> some View {
77 Section {
78 Picker("Theme", selection: $appTheme) {
79 ForEach(AppTheme.allCases) { theme in
80 Text(theme.label).tag(theme)
81 }
82 }
83 .themedRow()
84 Picker("Density", selection: $displayDensity) {
85 ForEach(DisplayDensity.allCases) { density in
86 Text(density.label).tag(density)
87 }
88 }
89 .themedRow()
90 } header: {
91 Text("Appearance")
92 } footer: {
93 Text("Compact density reduces spacing throughout the app.")
94 }
95 }
96
97 @ViewBuilder
98 private func behaviorSection() -> some View {
99 Section {
100 Toggle("Swipe actions", isOn: $swipeActionsEnabled)
101 .themedRow()
102 Toggle("Contribution graphs", isOn: $contributionGraphsEnabled)
103 .onChange(of: contributionGraphsEnabled) { _, newValue in
104 ContributionWidgetContextStore.setEnabled(newValue)
105 }
106 .themedRow()
107 Picker("Failed build window", selection: $failedBuildLookbackDays) {
108 ForEach(HomeViewModel.allowedFailedBuildLookbackDays, id: \.self) { days in
109 Text(failedBuildWindowLabel(days)).tag(days)
110 }
111 }
112 .themedRow()
113 } header: {
114 Text("Behavior")
115 } footer: {
116 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.")
117 }
118 }
119
120 @ViewBuilder
121 private func authenticationSection() -> some View {
122 Section {
123 HStack {
124 Image(systemName: "key.fill")
125 .foregroundStyle(.secondary)
126 VStack(alignment: .leading, spacing: 2) {
127 Text(appState.currentUser?.canonicalName ?? "No active account")
128 Text("\(appState.accounts.count) saved account\(appState.accounts.count == 1 ? "" : "s")")
129 .font(.caption)
130 .foregroundStyle(.secondary)
131 }
132 }
133 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
134 .themedRow()
135
136 Button {
137 showAccountSwitcher = true
138 } label: {
139 Label("Manage Accounts", systemImage: "person.2")
140 }
141 .themedRow()
142
143 HStack {
144 Image(systemName: "lock.shield")
145 .foregroundStyle(.secondary)
146 Text("Tokens are stored separately per account in the iOS keychain")
147 .font(.subheadline)
148 .foregroundStyle(.secondary)
149 }
150 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
151 .themedRow()
152
153 Button {
154 Task { await appState.client.clearPersistentCache() }
155 } label: {
156 Label("Clear Cache", systemImage: "externaldrive.badge.xmark")
157 }
158 .themedRow()
159
160 Button("Reset App Data", role: .destructive) {
161 pendingDestructiveAction = .resetAppData
162 }
163 .themedRow()
164
165 Button("Sign Out", role: .destructive) {
166 pendingDestructiveAction = .signOut
167 }
168 .themedRow()
169 } header: {
170 Text("Authentication")
171 } footer: {
172 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.")
173 }
174 }
175
176}
177
178private func failedBuildWindowLabel(_ days: Int) -> String {
179 if days == 1 {
180 return "Today only"
181 }
182 return "Last \(days) days"
183}
184
185func settingsBioAttributedString(_ markdown: String) -> AttributedString {
186 profileBioAttributedString(markdown)
187}
188
189private enum SettingsDestructiveAction {
190 case resetAppData
191 case signOut
192
193 var title: String {
194 switch self {
195 case .resetAppData:
196 "Reset App Data?"
197 case .signOut:
198 "Sign Out?"
199 }
200 }
201
202 var confirmationLabel: String {
203 switch self {
204 case .resetAppData:
205 "Reset App Data"
206 case .signOut:
207 "Sign Out"
208 }
209 }
210
211 var message: String {
212 switch self {
213 case .resetAppData:
214 "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
215 case .signOut:
216 "This signs you out of Hutch and clears saved authentication state on this device."
217 }
218 }
219}