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}