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 {
48 patSection(viewModel) 48 patSection(viewModel)
49 } 49 }
50 50
51 behaviorSection()
52
51 // Token / Sign Out 53 // Token / Sign Out
52 tokenSection() 54 tokenSection()
53 55
@@ -382,6 +384,25 @@ struct SettingsView: View {
382 384
383 // MARK: - Token / Sign Out Section 385 // MARK: - Token / Sign Out Section
384 386
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
385 @ViewBuilder 406 @ViewBuilder
386 private func tokenSection() -> some View { 407 private func tokenSection() -> some View {
387 Section { 408 Section {
@@ -727,6 +748,10 @@ private struct AboutView: View {
727 Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.") 748 Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
728 .font(.subheadline) 749 .font(.subheadline)
729 .foregroundStyle(.secondary) 750 .foregroundStyle(.secondary)
751
752 Link(destination: URL(string: "https://hutch.cleberg.net/privacy.html")!) {
753 SwiftUI.Label("Privacy Policy", systemImage: "hand.raised")
754 }
730 } 755 }
731 756
732 Section("Acknowledgements") { 757 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}