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}