Commit 948330fe88

948330fe881edc33089bcda026cd0ed4176bd6c1

parent: 0fb0b9f10d

Unsigned

cmc <hello@cleberg.net> · 2026-03-23 01:40 UTC

feat: add privacy policy link and global swipe actions toggle to Settings

Layout: unified · split

Hutch/App/AppStorageKeys.swift added +4
@@ -0,0 +1,4 @@
1// Shared UserDefaults key constants
2enum AppStorageKeys {
3 static let swipeActionsEnabled = "swipeActionsEnabled"
4}
Hutch/HutchTests/AppStateTests.swift added +25
@@ -0,0 +1,25 @@
1import Testing
2@testable import Hutch
3
4struct AppStateTests {
5
6 @Test
7 @MainActor
8 func presentRepositoryDeepLinkErrorSetsUserFacingMessage() {
9 let appState = AppState()
10
11 appState.presentRepositoryDeepLinkError()
12
13 #expect(appState.deepLinkError == "The repository could not be found or is inaccessible.")
14 }
15
16 @Test
17 @MainActor
18 func presentTicketDeepLinkErrorSetsUserFacingMessage() {
19 let appState = AppState()
20
21 appState.presentTicketDeepLinkError()
22
23 #expect(appState.deepLinkError == "The ticket could not be found or is inaccessible.")
24 }
25}
Hutch/Views/Settings/SettingsView.swift +25
@@ -48,6 +48,8 @@ struct SettingsView: View {
4848 patSection(viewModel)
4949 }
5050
51 behaviorSection()
52
5153 // Token / Sign Out
5254 tokenSection()
5355
@@ -382,6 +384,25 @@ struct SettingsView: View {
382384
383385 // MARK: - Token / Sign Out Section
384386
387 @ViewBuilder
388 private func behaviorSection() -> some View {
389 Section {
390 Toggle(
391 "Swipe actions",
392 isOn: Binding(
393 get: {
394 UserDefaults.standard.object(forKey: AppStorageKeys.swipeActionsEnabled) as? Bool ?? true
395 },
396 set: { UserDefaults.standard.set($0, forKey: AppStorageKeys.swipeActionsEnabled) }
397 )
398 )
399 } header: {
400 Text("Behavior")
401 } footer: {
402 Text("When enabled, swipe list rows to quickly take actions like resolving tickets, cancelling builds, and deleting pastes.")
403 }
404 }
405
385406 @ViewBuilder
386407 private func tokenSection() -> some View {
387408 Section {
@@ -727,6 +748,10 @@ private struct AboutView: View {
727748 Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
728749 .font(.subheadline)
729750 .foregroundStyle(.secondary)
751
752 Link(destination: URL(string: "https://hutch.cleberg.net/privacy.html")!) {
753 SwiftUI.Label("Privacy Policy", systemImage: "hand.raised")
754 }
730755 }
731756
732757 Section("Acknowledgements") {
Hutch/Views/Tickets/TicketCircleSwipeRow.swift added +141
@@ -0,0 +1,141 @@
1import SwiftUI
2
3struct SwipeCircleAction {
4 let label: String
5 let systemImage: String
6 let color: Color
7 let handler: () -> Void
8}
9
10struct TicketCircleSwipeRow<Content: View>: View {
11 let content: Content
12 let leadingAction: SwipeCircleAction?
13 let trailingAction: SwipeCircleAction?
14
15 @State private var offset: CGFloat = 0
16
17 private let circleSize: CGFloat = 60
18 private let revealDistance: CGFloat = 88
19 private let triggerDistance: CGFloat = 140
20
21 init(
22 leadingAction: SwipeCircleAction? = nil,
23 trailingAction: SwipeCircleAction? = nil,
24 @ViewBuilder content: () -> Content
25 ) {
26 self.leadingAction = leadingAction
27 self.trailingAction = trailingAction
28 self.content = content()
29 }
30
31 var body: some View {
32 ZStack(alignment: .center) {
33 if leadingAction != nil {
34 HStack {
35 leadingCircle
36 .padding(.leading, 16)
37 Spacer()
38 }
39 }
40
41 if trailingAction != nil {
42 HStack {
43 Spacer()
44 trailingCircle
45 .padding(.trailing, 16)
46 }
47 }
48
49 content
50 .offset(x: clampedOffset)
51 .gesture(dragGesture)
52 }
53 .clipped()
54 }
55
56 @ViewBuilder
57 private var leadingCircle: some View {
58 if let action = leadingAction {
59 let progress = min(max(offset / revealDistance, 0), 1)
60 circleButton(action: action, progress: progress)
61 }
62 }
63
64 @ViewBuilder
65 private var trailingCircle: some View {
66 if let action = trailingAction {
67 let progress = min(max(-offset / revealDistance, 0), 1)
68 circleButton(action: action, progress: progress)
69 }
70 }
71
72 @ViewBuilder
73 private func circleButton(action: SwipeCircleAction, progress: CGFloat) -> some View {
74 Button {
75 fire(action)
76 } label: {
77 VStack(spacing: 5) {
78 ZStack {
79 Circle()
80 .fill(action.color)
81 .frame(width: circleSize, height: circleSize)
82
83 Image(systemName: action.systemImage)
84 .font(.system(size: 22, weight: .medium))
85 .foregroundStyle(.white)
86 }
87
88 Text(action.label)
89 .font(.caption2.weight(.medium))
90 .foregroundStyle(.white)
91 }
92 }
93 .buttonStyle(.plain)
94 .opacity(progress)
95 .scaleEffect(0.7 + 0.3 * progress, anchor: .center)
96 .animation(.spring(duration: 0.2), value: progress)
97 }
98
99 private var clampedOffset: CGFloat {
100 if offset > 0 {
101 return leadingAction != nil ? min(offset, revealDistance + 16) : 0
102 } else {
103 return trailingAction != nil ? max(offset, -(revealDistance + 16)) : 0
104 }
105 }
106
107 private var dragGesture: some Gesture {
108 DragGesture(minimumDistance: 12, coordinateSpace: .local)
109 .onChanged { value in
110 let dx = value.translation.width
111 if dx > 0, leadingAction != nil {
112 offset = dx
113 } else if dx < 0, trailingAction != nil {
114 offset = dx
115 }
116 }
117 .onEnded { value in
118 let dx = value.translation.width
119 if dx > triggerDistance, let action = leadingAction {
120 fire(action)
121 } else if dx < -triggerDistance, let action = trailingAction {
122 fire(action)
123 } else {
124 snapBack()
125 }
126 }
127 }
128
129 private func fire(_ action: SwipeCircleAction) {
130 snapBack()
131 DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
132 action.handler()
133 }
134 }
135
136 private func snapBack() {
137 withAnimation(.spring(duration: 0.35, bounce: 0.2)) {
138 offset = 0
139 }
140 }
141}