krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.2.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("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}