Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public struct GoalDetailReducer {
public var isShowReactionBar: Bool { !isFrontMyCard && isCompleted }
public var isLoading: Bool { item == nil }
public var isFetchFailed: Bool = false
public var isRefreshing: Bool = false
public var isEditing: Bool = false
public var isSavingPhotoLog: Bool = false
public var pendingEditedImageData: Data?
Expand Down Expand Up @@ -151,6 +152,7 @@ public struct GoalDetailReducer {
case proofPhotoDismissed
case cameraPermissionAlertDismissed
case dataRetryTapped
case refreshPulled
}

// MARK: - Internal
Expand Down
152 changes: 152 additions & 0 deletions Projects/Feature/GoalDetail/Sources/Detail/GoalDetailDragState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//
// GoalDetailDragState.swift
// FeatureGoalDetail
//
// Created by Codex on 6/17/26.
//

import CoreGraphics

enum GoalDetailDragAxis {
case vertical
case horizontal
}

struct GoalDetailDragState {
var axis: GoalDetailDragAxis?
var pullOffset: CGFloat = .zero
var cardOffset: CGFloat = .zero
var isCrossingDuringCardDrag: Bool = false

mutating func updatePull(
translation: CGSize,
maxOffset: CGFloat
) {
resolveAxisIfNeeded(translation)

guard axis == .vertical, translation.height > 0 else {
pullOffset = .zero
return
}

pullOffset = min(translation.height, maxOffset)
}

func shouldRefresh(threshold: CGFloat) -> Bool {
pullOffset >= threshold
}

mutating func updateCard(
translation: CGSize,
velocityWidth: CGFloat,
maxCardOffset: CGFloat,
dragVelocityThreshold: CGFloat,
minimumDragResistance: CGFloat
) {
resolveAxisIfNeeded(translation)

guard axis == .horizontal else {
resetCard()
return
}

let width = resistedDragWidth(
for: translation.width,
velocity: velocityWidth,
dragVelocityThreshold: dragVelocityThreshold,
minimumDragResistance: minimumDragResistance
)

guard abs(width) >= abs(translation.height) else {
resetCard()
return
}

let maximumOffset = maxCardOffset * 2
guard (-maximumOffset...maximumOffset).contains(width) else {
return
}

cardOffset = repeatedCardOffset(
for: width,
maxCardOffset: maxCardOffset
)
isCrossingDuringCardDrag = shouldCrossCards(
for: width,
maxCardOffset: maxCardOffset
)
}

func shouldCompleteCardSwipe(
translation: CGSize,
velocityWidth: CGFloat,
dragVelocityThreshold: CGFloat,
minimumDragResistance: CGFloat
) -> Bool {
guard axis == .horizontal else { return false }

let width = resistedDragWidth(
for: translation.width,
velocity: velocityWidth,
dragVelocityThreshold: dragVelocityThreshold,
minimumDragResistance: minimumDragResistance
)
return abs(width) >= abs(translation.height)
}

mutating func resetCard() {
cardOffset = .zero
isCrossingDuringCardDrag = false
}

mutating func reset() {
axis = nil
pullOffset = .zero
resetCard()
}

private mutating func resolveAxisIfNeeded(_ translation: CGSize) {
guard axis == nil else { return }

axis = abs(translation.height) >= abs(translation.width)
? .vertical
: .horizontal
}

private func repeatedCardOffset(
for width: CGFloat,
maxCardOffset: CGFloat
) -> CGFloat {
let direction: CGFloat = width >= 0 ? 1 : -1
let progress = abs(width).truncatingRemainder(dividingBy: maxCardOffset * 2)
let offset = progress <= maxCardOffset ? progress : maxCardOffset * 2 - progress

return offset * direction
}

private func shouldCrossCards(
for width: CGFloat,
maxCardOffset: CGFloat
) -> Bool {
abs(width).truncatingRemainder(dividingBy: maxCardOffset * 2) > maxCardOffset
}

private func resistedDragWidth(
for proposedWidth: CGFloat,
velocity: CGFloat,
dragVelocityThreshold: CGFloat,
minimumDragResistance: CGFloat
) -> CGFloat {
let speed = abs(velocity)
guard speed > dragVelocityThreshold else {
return proposedWidth
}

let overflow = min(
(speed - dragVelocityThreshold) / dragVelocityThreshold,
1
)
let resistance = 1 - (overflow * (1 - minimumDragResistance))
return proposedWidth * resistance
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@ extension GoalDetailReducer {

case .view(.dataRetryTapped):
return .send(.view(.onAppear))

case .view(.refreshPulled):
let date = state.verificationDate
let goalId = state.goalId
state.isRefreshing = true
state.isFetchFailed = false

return .run { send in
do {
let item = try await goalClient.fetchGoalDetail(date, goalId)
await send(.response(.fethedGoalDetailItem(item)))
} catch {
await send(.response(.fetchGoalDetailFailed))
}
}

// MARK: - Action
case .view(.bottomButtonTapped):
Expand Down Expand Up @@ -211,6 +226,7 @@ extension GoalDetailReducer {
case let .response(.fethedGoalDetailItem(item)):
state.item = item
state.isFetchFailed = false
state.isRefreshing = false
if let goalIndex = state.completedGoalItems.firstIndex(where: {
$0.myPhotoLog?.goalId == state.goalId || $0.yourPhotoLog?.goalId == state.goalId
}) {
Expand All @@ -226,6 +242,7 @@ extension GoalDetailReducer {

case .response(.fetchGoalDetailFailed):
state.isFetchFailed = true
state.isRefreshing = false
return .none

case let .response(.updateCurrentCardReaction(photoLogId: photoLogId, reaction: reaction)):
Expand Down
Loading
Loading