krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.2.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 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("Reset App Data", role: .destructive) {
137 pendingDestructiveAction = .resetAppData
138 }
139 .themedRow()
140
141 Button("Sign Out", role: .destructive) {
142 pendingDestructiveAction = .signOut
143 }
144 .themedRow()
145 } header: {
146 Text("Authentication")
147 } footer: {
148 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.")
149 }
150 }
151
152}
153
154private func failedBuildWindowLabel(_ days: Int) -> String {
155 if days == 1 {
156 return "Today only"
157 }
158 return "Last \(days) days"
159}
160
161func settingsBioAttributedString(_ markdown: String) -> AttributedString {
162 profileBioAttributedString(markdown)
163}
164
165private enum SettingsDestructiveAction {
166 case resetAppData
167 case signOut
168
169 var title: String {
170 switch self {
171 case .resetAppData:
172 "Reset App Data?"
173 case .signOut:
174 "Sign Out?"
175 }
176 }
177
178 var confirmationLabel: String {
179 switch self {
180 case .resetAppData:
181 "Reset App Data"
182 case .signOut:
183 "Sign Out"
184 }
185 }
186
187 var message: String {
188 switch self {
189 case .resetAppData:
190 "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
191 case .signOut:
192 "This signs you out of Hutch and clears saved authentication state on this device."
193 }
194 }
195}