diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 7a8bfc65..3ae6d844 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -31,7 +31,7 @@ private let commonInfoPlist: [String: Plist.Value] = Project.Environment.InfoPli "DEEPLINK_HOST": "$(DEEPLINK_HOST)", "API_BASE_URL": "$(API_BASE_URL)", "NSCameraUsageDescription": "UseCamera", - "CFBundleShortVersionString": "1.1.3" + "CFBundleShortVersionString": "1.1.4" ], uniquingKeysWith: { current, _ in current }) private let commonDependencies: [TargetDependency] = [ diff --git a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift index b1f2e9c2..43094150 100644 --- a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift +++ b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift @@ -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? @@ -151,6 +152,7 @@ public struct GoalDetailReducer { case proofPhotoDismissed case cameraPermissionAlertDismissed case dataRetryTapped + case refreshPulled } // MARK: - Internal diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailDragState.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailDragState.swift new file mode 100644 index 00000000..9a79b386 --- /dev/null +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailDragState.swift @@ -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 + } +} diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift index 2997eb3f..e92ad89b 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift @@ -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): @@ -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 }) { @@ -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)): diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index 52d8379f..9eede9a2 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -37,8 +37,7 @@ public struct GoalDetailView: View { @State private var rectFrame: CGRect = .zero @State private var keyboardFrame: CGRect = .zero @StateObject private var myEmojiFlyingReactionEmitter = FlyingReactionEmitter() - @State private var cardOffset: CGFloat = .zero - @State private var isCrossingDuringDrag: Bool = false + @State private var dragState = GoalDetailDragState() /// GoalDetailView를 생성합니다. /// @@ -125,6 +124,19 @@ private extension GoalDetailView { store.send(.view(.dataRetryTapped)) } } else { + refreshableGoalContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + var refreshableGoalContent: some View { + ZStack(alignment: .top) { + TXLoadingIndicator() + .padding(.top, 16) + .opacity(dragState.pullOffset > 0 || store.isRefreshing ? 1 : 0) + + VStack(spacing: 0) { if store.item != nil { cardView .padding(.horizontal, 27) @@ -144,7 +156,12 @@ private extension GoalDetailView { Spacer() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .offset(y: dragState.pullOffset) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .contentShape(Rectangle()) + .simultaneousGesture(pullToRefreshGesture) } var navigationBar: some View { @@ -177,43 +194,34 @@ private extension GoalDetailView { DragGesture() .onChanged { value in guard !store.isEditing else { return } - let translation = value.translation - let width = resistedDragWidth( - for: translation.width, - velocity: value.velocity.width + dragState.updateCard( + translation: value.translation, + velocityWidth: value.velocity.width, + maxCardOffset: Constants.maxCardOffset, + dragVelocityThreshold: Constants.dragVelocityThreshold, + minimumDragResistance: Constants.minimumDragResistance ) - guard abs(width) >= abs(translation.height) else { - resetDragState() - return - } - - let maxOffset = Constants.maxCardOffset * 2 - - guard (-maxOffset...maxOffset).contains(width) else { - return - } - - cardOffset = repeatedCardOffset(for: width) - isCrossingDuringDrag = shouldCrossCards(for: width) } .onEnded { value in guard !store.isEditing else { return } - - let translation = value.translation - let width = resistedDragWidth( - for: translation.width, - velocity: value.velocity.width - ) - - guard abs(width) >= abs(translation.height) else { + guard dragState.axis != .vertical else { + dragState.resetCard() + return + } + guard dragState.shouldCompleteCardSwipe( + translation: value.translation, + velocityWidth: value.velocity.width, + dragVelocityThreshold: Constants.dragVelocityThreshold, + minimumDragResistance: Constants.minimumDragResistance + ) else { withAnimation(.spring(response: 0.2, dampingFraction: 0.94)) { - resetDragState() + dragState.reset() } return } withAnimation(.spring(response: 0.2, dampingFraction: 0.94)) { - resetDragState() + dragState.reset() store.send(.view(.cardSwiped)) } } @@ -235,7 +243,7 @@ private extension GoalDetailView { imageURL: store.myCard?.imageUrl, showsMyEmoji: effectiveIsFrontMyCard && store.selectedReactionEmoji != nil ) - .offset(x: cardOffset * (effectiveIsFrontMyCard ? 1 : -1)) + .offset(x: dragState.cardOffset * (effectiveIsFrontMyCard ? 1 : -1)) } @ViewBuilder @@ -247,7 +255,7 @@ private extension GoalDetailView { imageURL: store.partnerCard?.imageUrl, showsMyEmoji: false ) - .offset(x: cardOffset * (effectiveIsFrontMyCard ? -1 : 1)) + .offset(x: dragState.cardOffset * (effectiveIsFrontMyCard ? -1 : 1)) .rotationEffect(.degrees(-8)) } @@ -272,19 +280,23 @@ private extension GoalDetailView { } @ViewBuilder var reactionBar: some View { - ReactionBarView( - selectedEmoji: store.selectedReactionEmoji, - onSelect: { emoji in - store.send(.view(.reactionEmojiTapped(emoji))) - } - ) - .padding(.horizontal, Constants.reactionBarHorizontalPadding) - .position( - x: rectFrame.midX, - y: rectFrame.maxY - + Constants.reactionBarTopPadding - + Constants.reactionBarHeight / 2 - ) + GeometryReader { rootGeo in + let rootFrame = rootGeo.frame(in: .global) + + ReactionBarView( + selectedEmoji: store.selectedReactionEmoji, + onSelect: { emoji in + store.send(.view(.reactionEmojiTapped(emoji))) + } + ) + .padding(.horizontal, Constants.reactionBarHorizontalPadding) + .frame(maxWidth: .infinity, alignment: .top) + .offset( + y: rectFrame.maxY + - rootFrame.minY + + Constants.reactionBarTopPadding + ) + } } var backgroundCard: some View { @@ -454,7 +466,7 @@ private extension GoalDetailView { .padding(.bottom, 26) .frame(width: rectFrame.width, height: rectFrame.height, alignment: .bottom) .rotationEffect(frontCardRotation) - .offset(x: posX + cardOffset, y: posY - keyboardInset) + .offset(x: posX + dragState.cardOffset, y: posY - keyboardInset) .animation(.easeOut(duration: 0.25), value: keyboardInset) } } @@ -556,8 +568,40 @@ private extension GoalDetailView { // MARK: - Methods private extension GoalDetailView { + var pullToRefreshGesture: some Gesture { + DragGesture() + .onChanged { value in + guard !store.isEditing, + !store.isRefreshing, + !store.isLoading else { return } + + dragState.updatePull( + translation: value.translation, + maxOffset: Constants.maxPullToRefreshOffset + ) + } + .onEnded { _ in + guard dragState.axis != .horizontal else { return } + + let shouldRefresh = dragState.shouldRefresh( + threshold: Constants.pullToRefreshThreshold + ) + + withAnimation(.spring(response: 0.2, dampingFraction: 0.94)) { + dragState.reset() + } + + guard shouldRefresh, + !store.isEditing, + !store.isRefreshing, + !store.isLoading else { return } + + store.send(.view(.refreshPulled)) + } + } + var effectiveIsFrontMyCard: Bool { - isCrossingDuringDrag ? !store.isFrontMyCard : store.isFrontMyCard + dragState.isCrossingDuringCardDrag ? !store.isFrontMyCard : store.isFrontMyCard } var keyboardInset: CGFloat { @@ -572,37 +616,6 @@ private extension GoalDetailView { effectiveIsFrontMyCard ? store.myCardIsCompleted : store.partnerCardIsCompleted } - func repeatedCardOffset(for width: CGFloat) -> CGFloat { - let maxOffset = Constants.maxCardOffset - let direction: CGFloat = width >= 0 ? 1 : -1 - let progress = abs(width).truncatingRemainder(dividingBy: maxOffset * 2) - let offset = progress <= maxOffset ? progress : maxOffset * 2 - progress - - return offset * direction - } - - func shouldCrossCards(for width: CGFloat) -> Bool { - abs(width).truncatingRemainder(dividingBy: Constants.maxCardOffset * 2) > Constants.maxCardOffset - } - - func resistedDragWidth(for proposedWidth: CGFloat, velocity: CGFloat) -> CGFloat { - let speed = abs(velocity) - guard speed > Constants.dragVelocityThreshold else { - return proposedWidth - } - - let overflow = min( - (speed - Constants.dragVelocityThreshold) / Constants.dragVelocityThreshold, - 1 - ) - let resistance = 1 - (overflow * (1 - Constants.minimumDragResistance)) - return proposedWidth * resistance - } - - func resetDragState() { - cardOffset = .zero - isCrossingDuringDrag = false - } } // MARK: - Constants @@ -615,11 +628,12 @@ private extension GoalDetailView { static let maxCardOffset: CGFloat = 100 static let dragVelocityThreshold: CGFloat = 1200 static let minimumDragResistance: CGFloat = 0.35 - static var cardTopPadding: CGFloat { isSEDevice ? 34 : 89 } + static let pullToRefreshThreshold: CGFloat = 80 + static let maxPullToRefreshOffset: CGFloat = 120 + static var cardTopPadding: CGFloat { isSEDevice ? 46 : 103 } static var cardSize: CGFloat { isSEDevice ? 321 : 336 } - static let reactionBarHeight: CGFloat = 77 static let reactionBarHorizontalPadding: CGFloat = 20 - static var reactionBarTopPadding: CGFloat { isSEDevice ? 19 : 69 } + static var reactionBarTopPadding: CGFloat { isSEDevice ? 55 : 105 } } } diff --git a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift index 12dc9b3b..0d6f5478 100644 --- a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift +++ b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift @@ -72,6 +72,15 @@ extension HomeCoordinator { state.makeGoal = .init(mode: .edit(goalData)) return .none + case let .statsDetail(.delegate(.goToGoalDetail(id, owner, date))): + return .send( + .navigateToGoalDetail( + id: id, + owner: owner, + date: date + ) + ) + case .statsDetail(.view(.onDisappear)): if !state.routes.contains(.statsDetail) { state.statsDetail = nil diff --git a/Projects/Feature/ProofPhoto/Interface/Sources/ProofPhotoReducer.swift b/Projects/Feature/ProofPhoto/Interface/Sources/ProofPhotoReducer.swift index 7eedd7fc..cca2a18a 100644 --- a/Projects/Feature/ProofPhoto/Interface/Sources/ProofPhotoReducer.swift +++ b/Projects/Feature/ProofPhoto/Interface/Sources/ProofPhotoReducer.swift @@ -40,6 +40,7 @@ public struct ProofPhotoReducer { public var isCapturing: Bool = false public var isUploading: Bool = false public var hasImage: Bool { imageData != nil } + public var shouldShowComment: Bool = false public var toast: TXToastType? public var goalId: Int64 public var verificationDate: String diff --git a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoReducer+Impl.swift b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoReducer+Impl.swift index fd66f089..e4f2969c 100644 --- a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoReducer+Impl.swift +++ b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoReducer+Impl.swift @@ -229,6 +229,7 @@ extension ProofPhotoReducer { // MARK: - Update State case let .response(.setupCaptureSessionCompleted(session)): state.captureSession = session + state.shouldShowComment = true return .none case .response(.cameraSwitched): @@ -278,7 +279,9 @@ extension ProofPhotoReducer { case .binding: return .none - default: return .none + case .delegate: + state.shouldShowComment = false + return .none } } // swiftlint: enable closure_body_length diff --git a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift index 25066860..bc8821d1 100644 --- a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift +++ b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift @@ -113,7 +113,7 @@ private extension ProofPhotoView { } var shouldShowCommentOverlay: Bool { - (store.captureSession != nil || store.hasImage) && rectFrame != .zero + store.shouldShowComment && rectFrame != .zero } var topBar: some View { diff --git a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift index 637d5e60..ff7f9d02 100644 --- a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift @@ -92,9 +92,10 @@ public struct StatsDetailReducer { public init(goalId: Int64, initialMonth: TXCalendarDate) { self.goalId = goalId - self.currentMonth = initialMonth + let month = TXCalendarDate(year: initialMonth.year, month: initialMonth.month) + self.currentMonth = month self.monthlyData = TXCalendarDataGenerator.generateMonthData( - for: initialMonth, + for: month, hideAdjacentDates: true ) } @@ -150,7 +151,7 @@ public struct StatsDetailReducer { public enum Delegate { case navigateBack - case goToGoalDetail(goalId: Int64, isCompletedPartner: Bool, date: String) + case goToGoalDetail(goalId: Int64, owner: GoalDetail.Owner, date: String) case goToGoalEdit(EditableGoal) } diff --git a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift index 9e964d3a..0130a3f8 100644 --- a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift +++ b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift @@ -38,10 +38,10 @@ extension StatsCoordinator { state.statsDetail = .init(goalId: goalId, initialMonth: calendarDate) return .none - case let .statsDetail(.delegate(.goToGoalDetail(goalId, isCompletedPartner, date))): + case let .statsDetail(.delegate(.goToGoalDetail(goalId, owner, date))): state.routes.append(.goalDetail) state.goalDetail = .init( - currentUser: isCompletedPartner ? .you : .mySelf, + currentUser: owner, id: goalId, verificationDate: date ) diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift index c1332655..2a33b331 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift @@ -91,13 +91,13 @@ extension StatsDetailReducer { else { return .none } let dateString = txDate.formattedAPIDateString() let completedItem = state.completedDateByKey[dateString] - let isCompletedPartner = completedItem?.partnerImageUrl != nil + let owner: GoalDetail.Owner = completedItem?.partnerImageUrl != nil ? .you : .mySelf return .send( .delegate( .goToGoalDetail( goalId: state.goalId, - isCompletedPartner: isCompletedPartner, + owner: owner, date: dateString ) ) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Indicator/Loading/TXLoadingIndicator.swift b/Projects/Shared/DesignSystem/Sources/Components/Indicator/Loading/TXLoadingIndicator.swift index 5ff34a12..77c6cb80 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Indicator/Loading/TXLoadingIndicator.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Indicator/Loading/TXLoadingIndicator.swift @@ -7,10 +7,19 @@ import SwiftUI -struct TXLoadingIndicator: View { +/// 회전하는 로딩 인디케이터를 표시하는 View입니다. +public struct TXLoadingIndicator: View { @State private var rotation: Double = 0 - var body: some View { + /// TXLoadingIndicator를 생성합니다. + /// + /// ## 사용 예시 + /// ```swift + /// TXLoadingIndicator() + /// ``` + public init() {} + + public var body: some View { Circle() .trim(from: 0.175, to: 0.825) .stroke(