diff --git a/Gemfile b/Gemfile index 7a118b49..b2a45d3e 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,4 @@ source "https://rubygems.org" gem "fastlane" +gem "multi_json" diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index e329fdb3..75bd83c3 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -188,8 +188,22 @@ private extension GoalDetailView { cardOffset = repeatedCardOffset(for: width) isCrossingDuringDrag = shouldCrossCards(for: width) } - .onEnded { _ in + .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 { + withAnimation(.spring(response: 0.2, dampingFraction: 0.94)) { + resetDragState() + } + return + } + withAnimation(.spring(response: 0.2, dampingFraction: 0.94)) { resetDragState() store.send(.view(.cardSwiped)) @@ -431,7 +445,7 @@ private extension GoalDetailView { .padding(.bottom, 26) .frame(width: rectFrame.width, height: rectFrame.height, alignment: .bottom) .rotationEffect(frontCardRotation) - .offset(x: posX, y: posY - keyboardInset) + .offset(x: posX + cardOffset, y: posY - keyboardInset) .animation(.easeOut(duration: 0.25), value: keyboardInset) } } diff --git a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift index a9a47266..439d2af5 100644 --- a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift +++ b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift @@ -241,7 +241,7 @@ public struct HomeReducer { /// 홈 화면에서 외부로 전달하는 이벤트입니다. public enum Delegate { case goToGoalDetail(id: Int64, owner: GoalDetail.Owner, verificationDate: String) - case goToStatsDetail(id: Int64) + case goToStatsDetail(id: Int64, calendarDate: TXCalendarDate) case goToMakeGoal(GoalCategory) case goToEditGoalList(date: TXCalendarDate) case goToSettings diff --git a/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift b/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift index 6212798f..e056ef9b 100644 --- a/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift +++ b/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift @@ -10,42 +10,34 @@ import FeatureHomeInterface import SharedDesignSystem /// `hadFirstGoal`을 읽는 빈 상태 영역입니다. -/// `emptyScrollHeight`는 로컬 `@State`로 관리해 다른 콘텐츠 section 재렌더링에 의해 -/// 초기화되지 않게 합니다. +/// `goalEmptyView`의 center anchor를 기기 화면 기준 y축 중앙에 배치합니다. struct HomeEmptyContentSection: View { let store: StoreOf - @State private var emptyScrollHeight: CGFloat = 0 - var body: some View { VStack(spacing: 0) { - HomeHeaderRow(store: store) - .padding(.horizontal, 20) - .padding(.top, 16) + GeometryReader { geo in + let frame = geo.frame(in: .global) + let deviceHeight = UIScreen.main.bounds.height + let deviceCenterYInSection = max(0, deviceHeight / 2 - frame.minY) - ScrollView { - goalEmptyView - // 실제 가시 영역 기준으로 중앙 정렬되도록 탭바 높이만큼 차감 - .frame(maxWidth: .infinity, minHeight: max(0, emptyScrollHeight - 58)) - .padding(.bottom, 58) - } - .scrollIndicators(.hidden) - .refreshable { - store.send(.view(.refreshPulled)) - } - .overlay(alignment: .bottomTrailing) { - emptyArrow - } - .frame(maxHeight: .infinity) - .background { - GeometryReader { geo in - Color.clear - .onAppear { emptyScrollHeight = geo.size.height } - .onChange(of: geo.size.height) { _, newValue in - emptyScrollHeight = newValue - } + ScrollView { + goalEmptyView + .frame(width: geo.size.width) + .position(x: geo.size.width / 2, y: deviceCenterYInSection) + } + .scrollIndicators(.hidden) + .refreshable { + store.send(.view(.refreshPulled)) } + .overlay(alignment: .bottomTrailing) { + if store.hadFirstGoal == false { + emptyArrow + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } + .frame(maxHeight: .infinity) } } @@ -79,12 +71,11 @@ struct HomeEmptyContentSection: View { } } } - .frame(maxWidth: .infinity, maxHeight: .infinity) } var emptyArrow: some View { Image.Illustration.arrow - .padding(.bottom, 71 + 58) + .padding(.bottom, 71 + TXTabBarLayout.height) .padding(.trailing, 86) .ignoresSafeArea() } diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 14df2ed1..89bc05da 100644 --- a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift @@ -286,7 +286,7 @@ extension HomeReducer { return .send(.delegate(.goToGoalDetail(id: card.id, owner: .mySelf, verificationDate: verificationDate))) case let .view(.headerTapped(card)): - return .send(.delegate(.goToStatsDetail(id: card.id))) + return .send(.delegate(.goToStatsDetail(id: card.id, calendarDate: state.calendarDate))) case .view(.floatingButtonTapped): state.isAddGoalPresented = true diff --git a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift index 813e71b7..88025022 100644 --- a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift +++ b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift @@ -58,9 +58,9 @@ extension HomeCoordinator { state.notification = .init() return .none - case let .home(.delegate(.goToStatsDetail(id))): + case let .home(.delegate(.goToStatsDetail(id, date))): state.routes.append(.statsDetail) - state.statsDetail = .init(goalId: id) + state.statsDetail = .init(goalId: id, initialMonth: date) return .none case .statsDetail(.delegate(.navigateBack)): diff --git a/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift b/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift index 147d9888..7f94a323 100644 --- a/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift +++ b/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift @@ -177,7 +177,9 @@ private extension MakeGoalView { Spacer() if store.showPeriodCount { - dropDownButton { store.send(.view(.periodSelected)) } + dropDownButton(text: store.periodCountText) { + store.send(.view(.periodSelected)) + } } } } @@ -190,7 +192,9 @@ private extension MakeGoalView { Spacer() - dropDownButton { store.send(.view(.startDateTapped)) } + dropDownButton(text: store.startDateText) { + store.send(.view(.startDateTapped)) + } } .frame(height: 32) .padding(.vertical, 16) @@ -214,7 +218,9 @@ private extension MakeGoalView { Spacer() - dropDownButton { store.send(.view(.endDateTapped)) } + dropDownButton(text: store.endDateText) { + store.send(.view(.endDateTapped)) + } } .padding(.vertical, 21.5) } @@ -236,9 +242,12 @@ private extension MakeGoalView { .padding(.vertical, -1) } - func dropDownButton(_ action: @escaping () -> Void) -> some View { + func dropDownButton( + text: String, + action: @escaping () -> Void + ) -> some View { HStack(spacing: 0) { - Text(store.startDateText) + Text(text) .typography(.b2_14r) .foregroundStyle(Color.Gray.gray500) Image.Icon.Symbol.arrow2Down diff --git a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift index 72fee817..7fdc9cf4 100644 --- a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift @@ -18,7 +18,10 @@ import SharedDesignSystem /// ## 사용 예시 /// ```swift /// let store = Store( -/// initialState: StatsDetailReducer.State() +/// initialState: StatsDetailReducer.State( +/// goalId: 1, +/// initialMonth: TXCalendarDate() +/// ) /// ) { /// StatsDetailReducer(reducer: Reduce { _, _ in .none }) /// } @@ -78,15 +81,17 @@ public struct StatsDetailReducer { /// /// ## 사용 예시 /// ```swift - /// let state = StatsDetailReducer.State(goalId: 1) + /// let state = StatsDetailReducer.State( + /// goalId: 1, + /// initialMonth: TXCalendarDate() + /// ) /// ``` - public init(goalId: Int64) { + public init(goalId: Int64, initialMonth: TXCalendarDate) { self.goalId = goalId - - let currentMonth = TXCalendarDate() - self.currentMonth = currentMonth + + self.currentMonth = initialMonth self.monthlyData = TXCalendarDataGenerator.generateMonthData( - for: currentMonth, + for: initialMonth, hideAdjacentDates: true ) } diff --git a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift index 58e21b3f..19239028 100644 --- a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift @@ -109,7 +109,7 @@ public struct StatsReducer { /// StatsReducer가 상위 Coordinator로 전달하는 이벤트입니다. public enum Delegate { - case goToStatsDetail(goalId: Int64) + case goToStatsDetail(goalId: Int64, calendarDate: TXCalendarDate) } case view(View) diff --git a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift index 0b284b33..9e964d3a 100644 --- a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift +++ b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift @@ -33,9 +33,9 @@ extension StatsCoordinator { let reducer = Reduce { state, action in switch action { // MARK: - Child Action - case let .stats(.delegate(.goToStatsDetail(goalId))): + case let .stats(.delegate(.goToStatsDetail(goalId, calendarDate))): state.routes.append(.statsDetail) - state.statsDetail = .init(goalId: goalId) + state.statsDetail = .init(goalId: goalId, initialMonth: calendarDate) return .none case let .statsDetail(.delegate(.goToGoalDetail(goalId, isCompletedPartner, date))): diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift index 92f52b5c..c49c6fb8 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift @@ -156,8 +156,7 @@ private extension StatsDetailView { summaryTitle(for: summary.title) summartyContent(content: summary.content, isCompletedCount: summary.isCompletedCount) .layoutPriority(1) - - Spacer() + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -188,7 +187,6 @@ private extension StatsDetailView { Text(content[0]) .typography(.b4_12b) .foregroundStyle(Color.Gray.gray500) - .lineLimit(1) if isCompletedCount { Text("|") @@ -199,11 +197,9 @@ private extension StatsDetailView { Text(content[1]) .typography(.b4_12b) .foregroundStyle(Color.Gray.gray500) - .lineLimit(1) } } - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) + .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder @@ -266,7 +262,10 @@ private extension StatsDetailView { #Preview { StatsDetailView( store: Store( - initialState: StatsDetailReducer.State(goalId: 1), + initialState: StatsDetailReducer.State( + goalId: 1, + initialMonth: TXCalendarDate() + ), reducer: { StatsDetailReducer() } ) ) diff --git a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift index 349dac0a..cf073353 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift @@ -49,7 +49,8 @@ extension StatsReducer { return .send(.internal(.fetchStats)) case let .view(.statsCardTapped(goalId)): - return .send(.delegate(.goToStatsDetail(goalId: goalId))) + let date = state.isOngoing ? state.currentMonth : TXCalendarDate() + return .send(.delegate(.goToStatsDetail(goalId: goalId, calendarDate: date))) // MARK: - Update State case let .presentation(.showToast(toast)): diff --git a/Projects/Feature/Stats/Sources/Stats/StatsView.swift b/Projects/Feature/Stats/Sources/Stats/StatsView.swift index b5fa40fb..2c072863 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsView.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsView.swift @@ -114,7 +114,7 @@ private extension StatsView { if store.isOngoing { VStack(spacing: 8) { Image.Illustration.scare - Text("아직 목표가 없어요!") + Text("이 달은 목표가 없어요!") .typography(.t2_16b) .foregroundStyle(Color.Gray.gray400) } diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift index ea895461..b72bdaf1 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift @@ -9,19 +9,27 @@ import SwiftUI struct TXRoundButton: View { let shape: TXButtonShape + let allowsActionWhenDisabled: Bool let onTap: () -> Void public var body: some View { if case let .round(style, size, state) = shape { Button { - onTap() + switch state { + case .disabled: + if allowsActionWhenDisabled { + onTap() + } + case .standard: + onTap() + } } label: { - ZStack { + ZStack(alignment: .top) { Capsule() .fill(style.backgroundColor(state: state)) .frame(maxWidth: size.frameWidth) - .frame(height: size.backgroundHeight(state: state)) - .padding(.top, size.bottomYOffset(state: state)) + .frame(height: size.backgroundHeight) + .padding(.top, size.backgroundYOffset) Text(style.text) .typography(size.typography) @@ -34,8 +42,8 @@ struct TXRoundButton: View { lineWidth: size.borderWidth ) .background(style.foregroundColor(state: state), in: .capsule) + .padding(.top, size.foregroundYOffset(state: state)) } - .padding(.top, size.topYOffset(state: state)) } .buttonStyle(.plain) } else { @@ -112,29 +120,19 @@ private extension TXButtonShape.TXRoundSize { } } - func backgroundHeight(state: TXButtonShape.TXRoundState) -> CGFloat { + var backgroundHeight: CGFloat { switch self { case .l, .m: 70 - case .s: - switch state { - case .standard: 31 - case .disabled: 28 - } + case .s: 28 } } - func bottomYOffset(state: TXButtonShape.TXRoundState) -> CGFloat { - switch self { - case .s, .l, .m: - switch state { - case .standard: 4 - case .disabled: 1 - } - } + var backgroundYOffset: CGFloat { + 4 } - func topYOffset(state: TXButtonShape.TXRoundState) -> CGFloat { + func foregroundYOffset(state: TXButtonShape.TXRoundState) -> CGFloat { switch self { case .s, .l, .m: switch state { diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift index 6af90249..cf8d27e7 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift @@ -24,6 +24,7 @@ import SwiftUI /// ``` public struct TXButton: View { let shape: TXButtonShape + let allowsActionWhenDisabled: Bool let onTap: () -> Void /// 버튼을 생성합니다. @@ -41,9 +42,11 @@ public struct TXButton: View { /// ``` public init( shape: TXButtonShape, + allowsActionWhenDisabled: Bool = false, onTap: @escaping () -> Void ) { self.shape = shape + self.allowsActionWhenDisabled = allowsActionWhenDisabled self.onTap = onTap } @@ -65,6 +68,7 @@ public struct TXButton: View { case .round: TXRoundButton( shape: shape, + allowsActionWhenDisabled: allowsActionWhenDisabled, onTap: onTap ) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift index 524cb235..ae498178 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift @@ -169,6 +169,7 @@ private extension GoalCardView { size: .s, state: isButtonDisabled ? .disabled : .standard ), + allowsActionWhenDisabled: true, onTap: { buttonAction?() } ) .padding(.bottom, 14) @@ -179,7 +180,7 @@ private extension GoalCardView { .padding(.bottom, 10) } } - .frame(maxHeight: .infinity) + .frame(maxHeight: .infinity, alignment: .center) } func emojiImage(emoji: Image) -> some View {