diff options
| author | Christian Cleberg <[email protected]> | 2026-03-22 20:40:50 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-22 20:40:50 -0500 |
| commit | 866f9517dba3c878197b0290dd8b4d1becb153c3 (patch) | |
| tree | 94c9bbb873a628b6df7dd92e6071d2987d50bb28 | |
| parent | e60ff51620affd831202f06f710fcef6e1e92634 (diff) | |
| download | hutch-866f9517dba3c878197b0290dd8b4d1becb153c3.tar.gz hutch-866f9517dba3c878197b0290dd8b4d1becb153c3.tar.bz2 hutch-866f9517dba3c878197b0290dd8b4d1becb153c3.zip | |
feat: add privacy policy link and global swipe actions toggle to Settings
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 4 | ||||
| -rw-r--r-- | Hutch/HutchTests/AppStateTests.swift | 25 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SettingsView.swift | 25 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketCircleSwipeRow.swift | 141 |
4 files changed, 195 insertions, 0 deletions
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() @@ -383,6 +385,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 { HStack { @@ -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 + } + } +} |
