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}