krz/hutch

an ios client for sourcehut

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

866f9517dba3c878197b0290dd8b4d1becb153c3

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-23T01:40:50Z

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(+)

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<Content: View>: 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
+        }
+    }
+}