summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch/App/AppStorageKeys.swift4
-rw-r--r--Hutch/HutchTests/AppStateTests.swift25
-rw-r--r--Hutch/Views/Settings/SettingsView.swift25
-rw-r--r--Hutch/Views/Tickets/TicketCircleSwipeRow.swift141
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
+ }
+ }
+}