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