From 866f9517dba3c878197b0290dd8b4d1becb153c3 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 22 Mar 2026 20:40:50 -0500 Subject: feat: add privacy policy link and global swipe actions toggle to Settings --- Hutch/App/AppStorageKeys.swift | 4 + Hutch/HutchTests/AppStateTests.swift | 25 +++++ Hutch/Views/Settings/SettingsView.swift | 25 +++++ Hutch/Views/Tickets/TicketCircleSwipeRow.swift | 141 +++++++++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 Hutch/App/AppStorageKeys.swift create mode 100644 Hutch/HutchTests/AppStateTests.swift create mode 100644 Hutch/Views/Tickets/TicketCircleSwipeRow.swift diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift new file mode 100644 index 0000000..5c74de4 --- /dev/null +++ b/Hutch/App/AppStorageKeys.swift @@ -0,0 +1,4 @@ +// Shared UserDefaults key constants +enum AppStorageKeys { + static let swipeActionsEnabled = "swipeActionsEnabled" +} diff --git a/Hutch/HutchTests/AppStateTests.swift b/Hutch/HutchTests/AppStateTests.swift new file mode 100644 index 0000000..ca34df2 --- /dev/null +++ b/Hutch/HutchTests/AppStateTests.swift @@ -0,0 +1,25 @@ +import Testing +@testable import Hutch + +struct AppStateTests { + + @Test + @MainActor + func presentRepositoryDeepLinkErrorSetsUserFacingMessage() { + let appState = AppState() + + appState.presentRepositoryDeepLinkError() + + #expect(appState.deepLinkError == "The repository could not be found or is inaccessible.") + } + + @Test + @MainActor + func presentTicketDeepLinkErrorSetsUserFacingMessage() { + let appState = AppState() + + appState.presentTicketDeepLinkError() + + #expect(appState.deepLinkError == "The ticket could not be found or is inaccessible.") + } +} diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift index 91aa420..30758a8 100644 --- a/Hutch/Views/Settings/SettingsView.swift +++ b/Hutch/Views/Settings/SettingsView.swift @@ -48,6 +48,8 @@ struct SettingsView: View { patSection(viewModel) } + behaviorSection() + // Token / Sign Out tokenSection() @@ -382,6 +384,25 @@ struct SettingsView: View { // MARK: - Token / Sign Out Section + @ViewBuilder + private func behaviorSection() -> some View { + Section { + Toggle( + "Swipe actions", + isOn: Binding( + get: { + UserDefaults.standard.object(forKey: AppStorageKeys.swipeActionsEnabled) as? Bool ?? true + }, + set: { UserDefaults.standard.set($0, forKey: AppStorageKeys.swipeActionsEnabled) } + ) + ) + } header: { + Text("Behavior") + } footer: { + Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes.") + } + } + @ViewBuilder private func tokenSection() -> some View { Section { @@ -727,6 +748,10 @@ private struct AboutView: View { Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.") .font(.subheadline) .foregroundStyle(.secondary) + + Link(destination: URL(string: "https://hutch.cleberg.net/privacy.html")!) { + SwiftUI.Label("Privacy Policy", systemImage: "hand.raised") + } } Section("Acknowledgements") { diff --git a/Hutch/Views/Tickets/TicketCircleSwipeRow.swift b/Hutch/Views/Tickets/TicketCircleSwipeRow.swift new file mode 100644 index 0000000..6b676ce --- /dev/null +++ b/Hutch/Views/Tickets/TicketCircleSwipeRow.swift @@ -0,0 +1,141 @@ +import SwiftUI + +struct SwipeCircleAction { + let label: String + let systemImage: String + let color: Color + let handler: () -> Void +} + +struct TicketCircleSwipeRow: View { + let content: Content + let leadingAction: SwipeCircleAction? + let trailingAction: SwipeCircleAction? + + @State private var offset: CGFloat = 0 + + private let circleSize: CGFloat = 60 + private let revealDistance: CGFloat = 88 + private let triggerDistance: CGFloat = 140 + + init( + leadingAction: SwipeCircleAction? = nil, + trailingAction: SwipeCircleAction? = nil, + @ViewBuilder content: () -> Content + ) { + self.leadingAction = leadingAction + self.trailingAction = trailingAction + self.content = content() + } + + var body: some View { + ZStack(alignment: .center) { + if leadingAction != nil { + HStack { + leadingCircle + .padding(.leading, 16) + Spacer() + } + } + + if trailingAction != nil { + HStack { + Spacer() + trailingCircle + .padding(.trailing, 16) + } + } + + content + .offset(x: clampedOffset) + .gesture(dragGesture) + } + .clipped() + } + + @ViewBuilder + private var leadingCircle: some View { + if let action = leadingAction { + let progress = min(max(offset / revealDistance, 0), 1) + circleButton(action: action, progress: progress) + } + } + + @ViewBuilder + private var trailingCircle: some View { + if let action = trailingAction { + let progress = min(max(-offset / revealDistance, 0), 1) + circleButton(action: action, progress: progress) + } + } + + @ViewBuilder + private func circleButton(action: SwipeCircleAction, progress: CGFloat) -> some View { + Button { + fire(action) + } label: { + VStack(spacing: 5) { + ZStack { + Circle() + .fill(action.color) + .frame(width: circleSize, height: circleSize) + + Image(systemName: action.systemImage) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(.white) + } + + Text(action.label) + .font(.caption2.weight(.medium)) + .foregroundStyle(.white) + } + } + .buttonStyle(.plain) + .opacity(progress) + .scaleEffect(0.7 + 0.3 * progress, anchor: .center) + .animation(.spring(duration: 0.2), value: progress) + } + + private var clampedOffset: CGFloat { + if offset > 0 { + return leadingAction != nil ? min(offset, revealDistance + 16) : 0 + } else { + return trailingAction != nil ? max(offset, -(revealDistance + 16)) : 0 + } + } + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 12, coordinateSpace: .local) + .onChanged { value in + let dx = value.translation.width + if dx > 0, leadingAction != nil { + offset = dx + } else if dx < 0, trailingAction != nil { + offset = dx + } + } + .onEnded { value in + let dx = value.translation.width + if dx > triggerDistance, let action = leadingAction { + fire(action) + } else if dx < -triggerDistance, let action = trailingAction { + fire(action) + } else { + snapBack() + } + } + } + + private func fire(_ action: SwipeCircleAction) { + snapBack() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + action.handler() + } + } + + private func snapBack() { + withAnimation(.spring(duration: 0.35, bounce: 0.2)) { + offset = 0 + } + } +} -- cgit v1.2.3