diff --git a/Projects/Domain/Goal/Interface/Sources/Endpoint/GoalEndpoint.swift b/Projects/Domain/Goal/Interface/Sources/Endpoint/GoalEndpoint.swift index 4dd54243..348afbf1 100644 --- a/Projects/Domain/Goal/Interface/Sources/Endpoint/GoalEndpoint.swift +++ b/Projects/Domain/Goal/Interface/Sources/Endpoint/GoalEndpoint.swift @@ -13,7 +13,7 @@ import CoreNetworkInterface public enum GoalEndpoint: Endpoint { case fetchGoalList(date: String) case createGoal(GoalCreateRequestDTO) - case fetchGoalDetailList(date: String) + case fetchGoalDetail(date: String, goalId: Int64) case fetchGoalById(goalId: Int64) case fetchGoalEditList(date: String) case updateGoal(goalId: Int64, GoalUpdateRequestDTO) @@ -28,7 +28,7 @@ extension GoalEndpoint { case .createGoal: return "/api/v1/goals" - case .fetchGoalDetailList: + case .fetchGoalDetail: return "/api/v1/photologs" case let .fetchGoalById(goalId): @@ -56,7 +56,7 @@ extension GoalEndpoint { case .createGoal: return .post - case .fetchGoalDetailList: + case .fetchGoalDetail: return .get case .fetchGoalById: @@ -88,8 +88,11 @@ extension GoalEndpoint { case let .fetchGoalEditList(date): return [URLQueryItem(name: "date", value: date)] - case let .fetchGoalDetailList(date): - return [URLQueryItem(name: "targetDate", value: date)] + case let .fetchGoalDetail(date, goalId): + return [ + URLQueryItem(name: "targetDate", value: date), + URLQueryItem(name: "goalId", value: String(goalId)) + ] case .createGoal, .fetchGoalById, @@ -108,7 +111,7 @@ extension GoalEndpoint { case let .createGoal(request): return request - case .fetchGoalDetailList, + case .fetchGoalDetail, .fetchGoalById, .fetchGoalEditList, .deleteGoal, diff --git a/Projects/Domain/Goal/Interface/Sources/GoalClient.swift b/Projects/Domain/Goal/Interface/Sources/GoalClient.swift index f84400a1..a02d3f9d 100644 --- a/Projects/Domain/Goal/Interface/Sources/GoalClient.swift +++ b/Projects/Domain/Goal/Interface/Sources/GoalClient.swift @@ -20,7 +20,7 @@ import CoreNetworkInterface public struct GoalClient { public var fetchGoals: (String) async throws -> [Goal] public var createGoal: (GoalCreateRequestDTO) async throws -> Goal - public var fetchGoalDetailList: (String) async throws -> GoalDetail + public var fetchGoalDetail: (String, Int64) async throws -> GoalDetail public var fetchGoalById: (Int64) async throws -> Goal public var fetchGoalEditList: (String) async throws -> [Goal] public var updateGoal: (Int64, GoalUpdateRequestDTO) async throws -> Goal @@ -41,7 +41,7 @@ public struct GoalClient { public init( fetchGoals: @escaping (String) async throws -> [Goal], createGoal: @escaping (GoalCreateRequestDTO) async throws -> Goal, - fetchGoalDetailList: @escaping (String) async throws -> GoalDetail, + fetchGoalDetail: @escaping (String, Int64) async throws -> GoalDetail, fetchGoalById: @escaping (Int64) async throws -> Goal, fetchGoalEditList: @escaping (String) async throws -> [Goal], updateGoal: @escaping (Int64, GoalUpdateRequestDTO) async throws -> Goal, @@ -51,7 +51,7 @@ public struct GoalClient { ) { self.fetchGoals = fetchGoals self.createGoal = createGoal - self.fetchGoalDetailList = fetchGoalDetailList + self.fetchGoalDetail = fetchGoalDetail self.fetchGoalById = fetchGoalById self.fetchGoalEditList = fetchGoalEditList self.updateGoal = updateGoal @@ -83,8 +83,8 @@ extension GoalClient: TestDependencyKey { yourVerification: trashVerification ) }, - fetchGoalDetailList: { _ in - assertionFailure("GoalClient.fetchGoalDetailList이 구현되지 않았습니다. withDependencies로 mock을 주입하세요.") + fetchGoalDetail: { _, _ in + assertionFailure("GoalClient.fetchGoalDetail이 구현되지 않았습니다. withDependencies로 mock을 주입하세요.") return .init(partnerNickname: "", completedGoals: []) }, fetchGoalById: { _ in @@ -196,7 +196,7 @@ extension GoalClient: TestDependencyKey { yourVerification: .init(isCompleted: false, imageURL: nil, emoji: nil) ) }, - fetchGoalDetailList: { _ in + fetchGoalDetail: { _, _ in .init( partnerNickname: "민정", completedGoals: [ diff --git a/Projects/Domain/Goal/Sources/GoalClient+Live.swift b/Projects/Domain/Goal/Sources/GoalClient+Live.swift index a4c414b5..c5e64075 100644 --- a/Projects/Domain/Goal/Sources/GoalClient+Live.swift +++ b/Projects/Domain/Goal/Sources/GoalClient+Live.swift @@ -38,10 +38,10 @@ extension GoalClient: @retroactive DependencyKey { throw error } }, - fetchGoalDetailList: { date in + fetchGoalDetail: { date, goalId in do { let response: DetailGoalListResponseDTO = try await networkClient.request( - endpoint: GoalEndpoint.fetchGoalDetailList(date: date) + endpoint: GoalEndpoint.fetchGoalDetail(date: date, goalId: goalId) ) return response.toEntity(response) } catch { diff --git a/Projects/Domain/PhotoLog/Sources/PhotoLogClient+Live.swift b/Projects/Domain/PhotoLog/Sources/PhotoLogClient+Live.swift index 7eb07fd3..12a8ef28 100644 --- a/Projects/Domain/PhotoLog/Sources/PhotoLogClient+Live.swift +++ b/Projects/Domain/PhotoLog/Sources/PhotoLogClient+Live.swift @@ -31,7 +31,7 @@ extension PhotoLogClient: @retroactive DependencyKey { var request = URLRequest(url: url) request.httpMethod = "PUT" - request.setValue("image/png", forHTTPHeaderField: "Content-Type") + request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") _ = try await URLSession.shared.upload(for: request, from: data) }, diff --git a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift index e7de99c1..602ac1cf 100644 --- a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift +++ b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift @@ -21,7 +21,13 @@ public struct GoalDetailReducer { /// GoalDetail 화면 렌더링에 필요한 상태입니다. @ObservableState public struct State: Equatable { + public enum EntryPoint: Equatable { + case home + case stats + } + public let goalId: Int64 + public let entryPoint: EntryPoint public var item: GoalDetail? public var currentGoalIndex: Int = 0 public var currentUser: GoalDetail.Owner @@ -46,6 +52,26 @@ public struct GoalDetailReducer { } } + public var canSwipeLeft: Bool { + switch entryPoint { + case .home: + return !isEditing && currentUser == .you + + case .stats: + return !isEditing && currentUser == .mySelf + } + } + + public var canSwipeRight: Bool { + switch entryPoint { + case .home: + return !isEditing && currentUser == .mySelf + + case .stats: + return !isEditing && currentUser == .you + } + } + public var goalName: String { if let goalName = currentCompletedGoal?.goalName, !goalName.isEmpty { return goalName @@ -62,9 +88,6 @@ public struct GoalDetailReducer { ?? goalId } - public var canSwipeUp: Bool { currentGoalIndex + 1 < completedGoalItems.count } - public var canSwipeDown: Bool { currentGoalIndex > 0 } - public var isCompleted: Bool { pendingEditedImageData != nil || currentCard?.imageUrl != nil } @@ -104,10 +127,12 @@ public struct GoalDetailReducer { /// ``` public init( currentUser: GoalDetail.Owner, + entryPoint: EntryPoint, id: Int64, verificationDate: String ) { self.currentUser = currentUser + self.entryPoint = entryPoint self.goalId = id self.verificationDate = verificationDate } @@ -125,9 +150,8 @@ public struct GoalDetailReducer { case bottomButtonTapped case navigationBarTapped(TXNavigationBar.Action) case reactionEmojiTapped(ReactionEmoji) - case cardTapped - case cardSwipedUp - case cardSwipedDown + case cardSwipeLeft + case cardSwipeRight case focusChanged(Bool) case dimmedBackgroundTapped case updateMyPhotoLog(GoalDetail.CompletedGoal.PhotoLog) diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift index 4524e379..e5698a78 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift @@ -15,6 +15,7 @@ import FeatureGoalDetailInterface import FeatureProofPhotoInterface import SharedDesignSystem import SharedUtil +import SharedUtil extension GoalDetailReducer { // swiftlint: disable function_body_length @@ -40,10 +41,11 @@ extension GoalDetailReducer { // MARK: - LifeCycle case .onAppear: let date = state.verificationDate + let goalId = state.goalId return .run { send in do { - let item = try await goalClient.fetchGoalDetailList(date) + let item = try await goalClient.fetchGoalDetail(date, goalId) await send(.fethedGoalDetailItem(item)) } catch { await send(.fetchGoalDetailFailed) @@ -95,27 +97,15 @@ extension GoalDetailReducer { } ) - case .cardTapped: - state.currentUser = state.currentUser == .mySelf ? .you : .mySelf - state.commentText = state.comment - state.isCommentFocused = false - state.selectedReactionEmoji = state.currentCard?.reaction.flatMap(ReactionEmoji.init(from:)) - return .send(.setCreatedAt(timeFormatter.displayText(from: state.currentCard?.createdAt))) - - case .cardSwipedUp: - let nextIndex = state.currentGoalIndex + 1 - guard nextIndex < state.completedGoalItems.count else { return .none } - state.currentGoalIndex = nextIndex + case .cardSwipeLeft: + state.currentUser = .mySelf state.commentText = state.comment - state.isCommentFocused = false state.selectedReactionEmoji = state.currentCard?.reaction.flatMap(ReactionEmoji.init(from:)) return .send(.setCreatedAt(timeFormatter.displayText(from: state.currentCard?.createdAt))) - case .cardSwipedDown: - guard state.currentGoalIndex > 0 else { return .none } - state.currentGoalIndex -= 1 + case .cardSwipeRight: + state.currentUser = .you state.commentText = state.comment - state.isCommentFocused = false state.selectedReactionEmoji = state.currentCard?.reaction.flatMap(ReactionEmoji.init(from:)) return .send(.setCreatedAt(timeFormatter.displayText(from: state.currentCard?.createdAt))) @@ -219,8 +209,14 @@ extension GoalDetailReducer { do { var fileName: String if let pendingEditedImageData { + let optimizedImageData = ImageUploadOptimizer.optimizedJPEGData( + from: pendingEditedImageData + ) let uploadResponse = try await photoLogClient.fetchUploadURL(goalId) - try await photoLogClient.uploadImageData(pendingEditedImageData, uploadResponse.uploadUrl) + try await photoLogClient.uploadImageData( + optimizedImageData, + uploadResponse.uploadUrl + ) fileName = uploadResponse.fileName } else { let imageURLString = current.imageUrl ?? "" diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index 39d6c0b7..60bee841 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -58,72 +58,24 @@ public struct GoalDetailView: View { public var body: some View { VStack(spacing: 0) { - TXNavigationBar( - style: .subContent( - .init( - title: store.goalName, - rightContent: store.naviBarRightText.isEmpty - ? nil - : .text(store.naviBarRightText) - ) - ), - onAction: { action in - store.send(.navigationBarTapped(action)) - } - ) - .overlay(dimmedView) - - ScrollView { - ZStack(alignment: .bottom) { - if !store.isCompleted { - VStack { - Spacer() - bottomButton - .frame(maxWidth: .infinity) - } - } - - if !store.isCompleted { - VStack { - Spacer() - HStack { - Spacer() - pokeImage - .offset(x: -20, y: -20) - } - } - } - - VStack(spacing: 0) { - ZStack { - backgroundRect - - SwipeableCardView( - isEditing: store.isEditing, - canSwipeUp: store.canSwipeUp, - canSwipeDown: store.canSwipeDown, - onCardTap: { store.send(.cardTapped) }, - onSwipeUp: { store.send(.cardSwipedUp) }, - onSwipeDown: { store.send(.cardSwipedDown) } - ) { - currentCardView - } - } - .padding(.horizontal, 27) - .padding(.top, 103) - - if store.isCompleted { - completedBottomContent - } else { - Color.clear - .frame(height: 74) - .padding(.top, 105) - } + navigationBar + cardView + .padding(.horizontal, 27) + .padding(.top, isSEDevice ? 47 : 103) + + if store.isCompleted { + completedBottomContent + } else { + bottomButton + .padding(.top, 105) + .frame(maxWidth: .infinity) + .overlay(alignment: .topTrailing) { + pokeImage + .offset(x: -20, y: -20) } - } - .padding(.bottom, 40) } - .scrollIndicators(.hidden) + + Spacer() } .ignoresSafeArea(.keyboard) .background(dimmedView) @@ -158,6 +110,34 @@ public struct GoalDetailView: View { // MARK: - SubViews private extension GoalDetailView { + var navigationBar: some View { + TXNavigationBar( + style: .subContent( + .init( + title: store.goalName, + rightContent: store.naviBarRightText.isEmpty + ? nil + : .text(store.naviBarRightText) + ) + ), + onAction: { action in + store.send(.navigationBarTapped(action)) + } + ) + .overlay(dimmedView) + } + + var cardView: some View { + SwipeableCardView( + canSwipeLeft: store.canSwipeLeft, + canSwipeRight: store.canSwipeRight, + onSwipeLeft: { store.send(.cardSwipeLeft) }, + onSwipeRight: { store.send(.cardSwipeRight) }, + content: { currentCardView } + ) + .background(backgroundRect) + } + var currentCardView: some View { Group { if store.isCompleted { @@ -178,7 +158,8 @@ private extension GoalDetailView { shape: RoundedRectangle(cornerRadius: 20), lineWidth: 1.6 ) - .frame(width: 336, height: 336) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) .overlay(dimmedView) .clipShape(RoundedRectangle(cornerRadius: 20)) .rotationEffect(.degrees(degree(isBackground: true))) @@ -188,47 +169,18 @@ private extension GoalDetailView { var completedImageCard: some View { if let editImageData = store.pendingEditedImageData, let editedImage = UIImage(data: editImageData) { - Image(uiImage: editedImage) - .resizable() - .scaledToFill() - .frame(width: 336, height: 336) - .clipped() - .readSize { rectFrame = $0 } - .overlay(dimmedView) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .overlay(alignment: .bottom) { - if let comment = store.currentCard?.comment, !comment.isEmpty { - commentCircle - .padding(.bottom, 26) - } - } - .insideBorder( - Color.Gray.gray500, - shape: RoundedRectangle(cornerRadius: 20), - lineWidth: 1.6 - ) - .rotationEffect(.degrees(degree(isBackground: false))) + completedImageCardContainer { + Image(uiImage: editedImage) + .resizable() + .scaledToFill() + } } else if let imageUrl = store.currentCard?.imageUrl, let url = URL(string: imageUrl) { - KFImage(url) - .resizable() - .scaledToFill() - .frame(width: 336, height: 336) - .readSize { rectFrame = $0 } - .overlay(dimmedView) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .overlay(alignment: .bottom) { - if let comment = store.currentCard?.comment, !comment.isEmpty { - commentCircle - .padding(.bottom, 26) - } - } - .insideBorder( - Color.Gray.gray500, - shape: RoundedRectangle(cornerRadius: 20), - lineWidth: 1.6 - ) - .rotationEffect(.degrees(degree(isBackground: false))) + completedImageCardContainer { + KFImage(url) + .resizable() + .scaledToFill() + } } else { EmptyView() } @@ -248,8 +200,8 @@ private extension GoalDetailView { if store.isShowReactionBar { reactionBar - .padding(.top, 73) - .padding(.horizontal, 21) + .padding(.top, isSEDevice ? 23 : 73) + .padding(.horizontal, 20) } } @@ -270,16 +222,22 @@ private extension GoalDetailView { } var nonCompletedCard: some View { - RoundedRectangle(cornerRadius: 20) - .fill(.white) + let shape = RoundedRectangle(cornerRadius: 20) + + return Color.clear + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + .overlay { + shape + .fill(Color.Common.white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .clipShape(shape) .insideBorder( Color.Gray.gray500, - shape: RoundedRectangle(cornerRadius: 20), + shape: shape, lineWidth: 1.6 ) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 20)) .rotationEffect(.degrees(degree(isBackground: false))) } @@ -332,6 +290,36 @@ private extension GoalDetailView { store.send(.dimmedBackgroundTapped) } } + + func completedImageCardContainer( + @ViewBuilder content: @escaping () -> Content + ) -> some View { + let shape = RoundedRectangle(cornerRadius: 20) + + return Color.clear + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + .readSize { rectFrame = $0 } + .overlay { + content() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + .overlay(dimmedView) + .clipShape(shape) + .overlay(alignment: .bottom) { + if let comment = store.currentCard?.comment, !comment.isEmpty { + commentCircle + .padding(.bottom, 26) + } + } + .insideBorder( + Color.Gray.gray500, + shape: shape, + lineWidth: 1.6 + ) + .rotationEffect(.degrees(degree(isBackground: false))) + } } // MARK: - Constants @@ -345,6 +333,11 @@ private extension GoalDetailView { return isBackground ? 0 : -8 } } + + // 다른곳에서도 쓸 때 Util로 빼기 + private var isSEDevice: Bool { + UIScreen.main.bounds.height <= 667 + } } #Preview { @@ -352,6 +345,7 @@ private extension GoalDetailView { store: Store( initialState: GoalDetailReducer.State( currentUser: .mySelf, + entryPoint: .home, id: 1, verificationDate: "2026-02-07" ), diff --git a/Projects/Feature/GoalDetail/Sources/Detail/SwipeableCardView.swift b/Projects/Feature/GoalDetail/Sources/Detail/SwipeableCardView.swift index 810e4e50..9d551563 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/SwipeableCardView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/SwipeableCardView.swift @@ -9,19 +9,11 @@ import SwiftUI struct SwipeableCardView: View { enum SwipeDirection { - case up - case down case left case right var exitOffset: CGSize { switch self { - case .up: - return CGSize(width: 0, height: -420) - - case .down: - return CGSize(width: 0, height: 420) - case .left: return CGSize(width: -420, height: 0) @@ -30,33 +22,26 @@ struct SwipeableCardView: View { } } } - - let isEditing: Bool - let canSwipeUp: Bool - let canSwipeDown: Bool - let onCardTap: () -> Void - let onSwipeUp: () -> Void - let onSwipeDown: () -> Void + let canSwipeLeft: Bool + let canSwipeRight: Bool + let onSwipeLeft: () -> Void + let onSwipeRight: () -> Void let content: Content @State private var cardOffset: CGSize = .zero @State private var cardOpacity: Double = 1 init( - isEditing: Bool, - canSwipeUp: Bool, - canSwipeDown: Bool, - onCardTap: @escaping () -> Void, - onSwipeUp: @escaping () -> Void, - onSwipeDown: @escaping () -> Void, + canSwipeLeft: Bool, + canSwipeRight: Bool, + onSwipeLeft: @escaping () -> Void, + onSwipeRight: @escaping () -> Void, @ViewBuilder content: () -> Content ) { - self.isEditing = isEditing - self.canSwipeUp = canSwipeUp - self.canSwipeDown = canSwipeDown - self.onCardTap = onCardTap - self.onSwipeUp = onSwipeUp - self.onSwipeDown = onSwipeDown + self.canSwipeLeft = canSwipeLeft + self.canSwipeRight = canSwipeRight + self.onSwipeLeft = onSwipeLeft + self.onSwipeRight = onSwipeRight self.content = content() } @@ -66,11 +51,7 @@ struct SwipeableCardView: View { .opacity(cardOpacity) .rotationEffect(.degrees(swipeRotation)) .contentShape(RoundedRectangle(cornerRadius: 20)) - .onTapGesture { handleCardTap() } .gesture(cardSwipeGesture) - .onChange(of: isEditing) { - if isEditing { resetCardOffset() } - } } } @@ -83,52 +64,43 @@ private extension SwipeableCardView { var cardSwipeGesture: some Gesture { DragGesture(minimumDistance: 16) .onChanged { value in - guard !isEditing else { return } - cardOffset = value.translation - } - .onEnded { value in - guard !isEditing else { - resetCardOffset() + let translation = value.translation + + guard abs(translation.width) >= abs(translation.height) else { + cardOffset = .zero return } - + + cardOffset = CGSize(width: translation.width, height: 0) + } + .onEnded { value in guard let direction = swipeDirection(for: value.translation) else { resetCardOffset() return } - if direction == .up && !canSwipeUp { - resetCardOffset() - return + switch direction { + case .left: + if !canSwipeLeft { + resetCardOffset() + return + } + + case .right: + if !canSwipeRight { + resetCardOffset() + return + } } - - if direction == .down && !canSwipeDown { - resetCardOffset() - return - } - completeSwipe(direction: direction) } } - func handleCardTap() { - guard !isEditing else { return } - withAnimation(.spring(response: 0.36, dampingFraction: 0.86)) { - onCardTap() - } - } - func swipeDirection(for translation: CGSize) -> SwipeDirection? { let threshold: CGFloat = 60 - let isHorizontal = abs(translation.width) > abs(translation.height) - - if isHorizontal { - guard abs(translation.width) > threshold else { return nil } - return translation.width > 0 ? .right : .left - } else { - guard abs(translation.height) > threshold else { return nil } - return translation.height > 0 ? .down : .up - } + guard abs(translation.width) > abs(translation.height) else { return nil } + guard abs(translation.width) > threshold else { return nil } + return translation.width > 0 ? .right : .left } func completeSwipe(direction: SwipeDirection) { @@ -142,12 +114,13 @@ private extension SwipeableCardView { Task { @MainActor in try await Task.sleep(for: .seconds(0.15)) switch direction { - case .up: - onSwipeUp() - case .down: - onSwipeDown() - case .left, .right: - onCardTap() + case .left: + guard canSwipeLeft else { return } + onSwipeLeft() + + case .right: + guard canSwipeRight else { return } + onSwipeRight() } cardOffset = CGSize(width: -exitOffset.width * 0.2, height: -exitOffset.height * 0.2) withAnimation(.spring(response: 0.34, dampingFraction: 0.84)) { diff --git a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift index 68412287..7d3c98b6 100644 --- a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift +++ b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift @@ -44,6 +44,7 @@ public struct HomeReducer { public var calendarWeeks: [[TXCalendarDateItem]] = [] public var calendarDate: TXCalendarDate = .init() public var calendarSheetDate: TXCalendarDate = .init() + public var goalsCache: [String: [GoalCardItem]] = [:] public var isRefreshHidden: Bool = true public var isCalendarSheetPresented: Bool = false public var pendingDeleteGoalID: Int64? @@ -98,6 +99,7 @@ public struct HomeReducer { // MARK: - User Action case calendarDateSelected(TXCalendarDateItem) + case weekCalendarSwipe(TXCalendar.SwipeGesture) case navigationBarAction(TXNavigationBar.Action) case monthCalendarConfirmTapped case goalCheckButtonTapped(id: Int64, isChecked: Bool) @@ -106,7 +108,6 @@ public struct HomeReducer { case myCardTapped(GoalCardItem) case floatingButtonTapped case editButtonTapped - case weekCalendarSwipe(TXCalendar.SwipeGesture) // MARK: - Update State case fetchGoals diff --git a/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift b/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift index c7c37de5..7a7a2174 100644 --- a/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift +++ b/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift @@ -76,7 +76,7 @@ private extension EditGoalListView { onSelect: { item in store.send(.calendarDateSelected(item)) }, - onWeekSwipe: { swipe in + onSwipe: { swipe in store.send(.weekCalendarSwipe(swipe)) } ) diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 8b3e0a85..b71918c5 100644 --- a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift @@ -160,9 +160,20 @@ extension HomeReducer { state.modal = .info(.uncheckGoal) return .none } else { - return .run { send in - let isAuthorized = await captureSessionClient.fetchIsAuthorized() - await send(.authorizationCompleted(id: id, isAuthorized: isAuthorized)) + let now = state.nowDate + let today = TXCalendarDate( + year: now.year, + month: now.month, + day: now.day + ) + if state.calendarDate > today { + state.toast = .warning(message: "미래의 인증샷은 지금 올릴 수 없어요!") + return .none + } else { + return .run { send in + let isAuthorized = await captureSessionClient.fetchIsAuthorized() + await send(.authorizationCompleted(id: id, isAuthorized: isAuthorized)) + } } } @@ -242,6 +253,9 @@ extension HomeReducer { // MARK: - Update State case let .fetchGoalsCompleted(items, date): + let cacheKey = TXCalendarUtil.apiDateString(for: date) + state.goalsCache[cacheKey] = items + if date != state.calendarDate { return .none } @@ -273,6 +287,13 @@ extension HomeReducer { case .fetchGoals: let date = state.calendarDate + let cacheKey = TXCalendarUtil.apiDateString(for: date) + if let cachedItems = state.goalsCache[cacheKey] { + state.cards = cachedItems + state.isLoading = false + } else { + state.isLoading = true + } return .run { send in // 읽지 않은 알림 여부 체크 if let hasUnread = try? await notificationClient.fetchUnread() { @@ -280,7 +301,7 @@ extension HomeReducer { } do { - let goals = try await goalClient.fetchGoals(TXCalendarUtil.apiDateString(for: date)) + let goals = try await goalClient.fetchGoals(cacheKey) let items: [GoalCardItem] = goals.map { goal in let myImageURL = goal.myVerification?.imageURL.flatMap(URL.init(string:)) let yourImageURL = goal.yourVerification?.imageURL.flatMap(URL.init(string:)) @@ -343,6 +364,7 @@ extension HomeReducer { isSelected: true, emoji: state.cards[index].myCard.emoji ) + state.goalsCache[TXCalendarUtil.apiDateString(for: state.calendarDate)] = state.cards return .none case .proofPhotoDismissed: @@ -362,6 +384,7 @@ extension HomeReducer { isSelected: false, emoji: state.cards[index].myCard.emoji ) + state.goalsCache[TXCalendarUtil.apiDateString(for: state.calendarDate)] = state.cards return .send(.showToast(.delete(message: "인증이 해제되었어요"))) case .deletePhotoLogFailed: diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index f372f8b2..a1c817f2 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -89,10 +89,6 @@ public struct HomeView: View { } } ) - .txToast( - item: $store.toast, - onButtonTap: { } - ) .transaction { transaction in transaction.disablesAnimations = false } @@ -137,7 +133,7 @@ private extension HomeView { onSelect: { item in store.send(.calendarDateSelected(item)) }, - onWeekSwipe: { swipe in + onSwipe: { swipe in store.send(.weekCalendarSwipe(swipe)) } ) diff --git a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift index ac303ae1..68a91264 100644 --- a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift +++ b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift @@ -29,7 +29,12 @@ extension HomeCoordinator { switch action { case let .home(.delegate(.goToGoalDetail(id, owner, verificationDate))): state.routes.append(.detail) - state.goalDetail = .init(currentUser: owner, id: id, verificationDate: verificationDate) + state.goalDetail = .init( + currentUser: owner, + entryPoint: .home, + id: id, + verificationDate: verificationDate + ) return .none case let .home(.delegate(.goToMakeGoal(category))): @@ -41,12 +46,12 @@ extension HomeCoordinator { state.routes.append(.editGoalList) state.editGoalList = .init(calendarDate: date) return .none - + case .home(.delegate(.goToSettings)): state.routes.append(.settings) state.settings = .init() return .none - + case .home(.delegate(.goToNotification)): state.routes.append(.notification) state.notification = .init() @@ -89,66 +94,71 @@ extension HomeCoordinator { case .editGoalList: return .none - + case .settings(.delegate(.navigateBack)): popLastRoute(&state.routes) state.settings = nil return .none - + case .settings(.delegate(.navigateBackFromSubView)): popLastRoute(&state.routes) return .none - + case .settings(.delegate(.navigateToAccount)): state.routes.append(.settingsAccount) return .none - + case .settings(.delegate(.navigateToInfo)): state.routes.append(.settingsInfo) return .none - + case .settings(.delegate(.navigateToNotificationSettings)): state.routes.append(.settingsNotificationSettings) return .none - + case let .settings(.delegate(.navigateToWebView(url, title))): state.routes.append(.settingsWebView(url: url, title: title)) return .none - + case .settings(.delegate(.logoutCompleted)): state.routes.removeAll() state.settings = nil return .send(.delegate(.logoutCompleted)) - + case .settings(.delegate(.withdrawCompleted)): state.routes.removeAll() state.settings = nil return .send(.delegate(.withdrawCompleted)) - + case .settings(.delegate(.sessionExpired)): state.routes.removeAll() state.settings = nil return .send(.delegate(.sessionExpired)) - + case .settings: return .none - + case .notification(.delegate(.navigateBack)): popLastRoute(&state.routes) state.notification = nil return .none - + case let .notification(.delegate(.notificationSelected(item))): popLastRoute(&state.routes) state.notification = nil return .send(.delegate(.notificationItemTapped(item))) - + case .notification: return .none - + case let .navigateToGoalDetail(id, owner, date): state.routes.append(.detail) - state.goalDetail = .init(currentUser: owner, id: id, verificationDate: date) + state.goalDetail = .init( + currentUser: owner, + entryPoint: .stats, + id: id, + verificationDate: date + ) return .none case .delegate: diff --git a/Projects/Feature/MainTab/Sources/View/MainTabView.swift b/Projects/Feature/MainTab/Sources/View/MainTabView.swift index e2b21e58..97791066 100644 --- a/Projects/Feature/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Feature/MainTab/Sources/View/MainTabView.swift @@ -62,6 +62,10 @@ public struct MainTabView: View { homeFloatingButton } } + .txToast( + item: $store.home.home.toast, + customPadding: Constants.tabBarHeight + ) } } diff --git a/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift b/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift index c4ae0dee..6798718c 100644 --- a/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift +++ b/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift @@ -47,13 +47,16 @@ public struct MakeGoalView: View { .toolbar(.hidden, for: .navigationBar) .onAppear { store.send(.onAppear) } .onDisappear { store.send(.onDisappear) } - .calendarSheet( - isPresented: $store.isCalendarSheetPresented, - selectedDate: $store.calendarSheetDate, - completeButtonText: "완료", - onComplete: { store.send(.monthCalendarConfirmTapped) }, - isDateEnabled: store.isCalendarDateEnabled - ) + .txBottomSheet( + isPresented: $store.isCalendarSheetPresented + ) { + TXCalendarBottomSheet( + selectedDate: $store.calendarSheetDate, + completeButtonText: "완료", + onComplete: { store.send(.monthCalendarConfirmTapped) }, + isDateEnabled: store.isCalendarDateEnabled + ) + } .txBottomSheet( isPresented: $store.isPeriodSheetPresented ) { diff --git a/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayView.swift b/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayView.swift index 2664211e..1f85306c 100644 --- a/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayView.swift +++ b/Projects/Feature/Onboarding/Sources/Dday/OnboardingDdayView.swift @@ -45,18 +45,21 @@ public struct OnboardingDdayView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.Common.white) - .calendarSheet( - isPresented: $store.showCalendarSheet, - selectedDate: $store.selectedDate, - onComplete: { store.send(.calendarCompleted) }, - isDateEnabled: { item in - guard let components = item.dateComponents, - let date = Calendar.current.date(from: components) else { - return true + .txBottomSheet( + isPresented: $store.showCalendarSheet + ) { + TXCalendarBottomSheet( + selectedDate: $store.selectedDate, + onComplete: { store.send(.calendarCompleted) }, + isDateEnabled: { item in + guard let components = item.dateComponents, + let date = Calendar.current.date(from: components) else { + return true + } + return date <= Date() } - return date <= Date() - } - ) + ) + } } } diff --git a/Projects/Feature/ProofPhoto/Sources/ProofPhotoReducer+Impl.swift b/Projects/Feature/ProofPhoto/Sources/ProofPhotoReducer+Impl.swift index 4c9c829f..ebc50b71 100644 --- a/Projects/Feature/ProofPhoto/Sources/ProofPhotoReducer+Impl.swift +++ b/Projects/Feature/ProofPhoto/Sources/ProofPhotoReducer+Impl.swift @@ -13,6 +13,7 @@ import DomainPhotoLogInterface import FeatureProofPhotoInterface import PhotosUI import SharedDesignSystem +import SharedUtil extension ProofPhotoReducer { // swiftlint: disable function_body_length @@ -130,8 +131,9 @@ extension ProofPhotoReducer { } return .run { send in do { + let optimizedImageData = ImageUploadOptimizer.optimizedJPEGData(from: imageData) let uploadResponse = try await photoLogClient.fetchUploadURL(goalId) - try await photoLogClient.uploadImageData(imageData, uploadResponse.uploadUrl) + try await photoLogClient.uploadImageData(optimizedImageData, uploadResponse.uploadUrl) let createRequest = PhotoLogCreateRequestDTO( goalId: goalId, diff --git a/Projects/Feature/ProofPhoto/Sources/ProofPhotoView.swift b/Projects/Feature/ProofPhoto/Sources/ProofPhotoView.swift index 0198ee7f..523d6dad 100644 --- a/Projects/Feature/ProofPhoto/Sources/ProofPhotoView.swift +++ b/Projects/Feature/ProofPhoto/Sources/ProofPhotoView.swift @@ -64,6 +64,11 @@ public struct ProofPhotoView: View { if shouldShowCommentOverlay { floatingCommentOverlay } + + if store.isUploading { + ProgressView() + .scaleEffect(1.2) + } } .ignoresSafeArea(.keyboard) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) diff --git a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift index 57128fe6..5d1f67f5 100644 --- a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift @@ -99,6 +99,7 @@ public struct StatsDetailReducer { case navigationBarTapped(TXNavigationBar.Action) case previousMonthTapped case nextMonthTapped + case calendarSwiped(TXCalendar.SwipeGesture) case calendarCellTapped(TXCalendarDateItem) case dropDownSelected(TXDropdownItem) case backgroundTapped diff --git a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift index 6284196e..9b89f92a 100644 --- a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift +++ b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift @@ -43,6 +43,7 @@ extension StatsCoordinator { state.routes.append(.goalDetail) state.goalDetail = .init( currentUser: isCompletedPartner ? .you : .mySelf, + entryPoint: .stats, 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 78a24010..5442e6e4 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift @@ -64,7 +64,14 @@ extension StatsDetailReducer { hideAdjacentDates: true ) return .send(.fetchStatsDetail) - + + case let .calendarSwiped(swipe): + switch swipe { + case .previous: + return .send(.previousMonthTapped) + case .next: + return .send(.nextMonthTapped) + } case let .calendarCellTapped(item): guard let dateComponents = item.dateComponents, diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift index 661a81ba..1f27b427 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift @@ -30,6 +30,7 @@ struct StatsDetailView: View { } .padding(.horizontal, 20) } + .background(Color.Gray.gray50) .overlay { if store.isLoading { ProgressView() @@ -95,6 +96,8 @@ private extension StatsDetailView { TXCalendar( mode: .monthly, weeks: store.monthlyData, + canMovePrevious: !store.previousMonthDisabled, + canMoveNext: !store.nextMonthDisabled, config: .init( dateCellBackground: { item in guard let completedDate = completedDate(for: item) else { return nil } @@ -111,6 +114,9 @@ private extension StatsDetailView { if item.status == .completed { store.send(.calendarCellTapped(item)) } + }, + onSwipe: { swipe in + store.send(.calendarSwiped(swipe)) } ) .padding(.vertical, 24) diff --git a/Projects/Feature/Stats/Sources/Stats/StatsView.swift b/Projects/Feature/Stats/Sources/Stats/StatsView.swift index 0a747a04..414b12ae 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsView.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsView.swift @@ -20,12 +20,10 @@ struct StatsView: View { topTabBar monthNavigation .padding(.top, store.isOngoing ? 16 : 20) + .background(Color.Gray.gray50) if store.hasItems { cardList - } else { - // TODO: - 디자인 확정되면 구현 - EmptyView() } Spacer() @@ -34,6 +32,10 @@ struct StatsView: View { if store.isLoading { ProgressView() } + + if !store.hasItems { + statsEmptyView + } } .onAppear { store.send(.onAppear) } .txToast(item: $store.toast) @@ -84,5 +86,24 @@ private extension StatsView { .padding(.top, 12) .padding([.horizontal, .bottom], 20) } + .background(Color.Gray.gray50) + } + + var statsEmptyView: some View { + if store.isOngoing { + VStack(spacing: 8) { + Image.Illustration.scare + Text("아직 목표가 없어요!") + .typography(.t2_16b) + .foregroundStyle(Color.Gray.gray400) + } + } else { + VStack(spacing: 8) { + Image.Illustration.trash + Text("아직 끝낸 목표가 없어요!") + .typography(.t2_16b) + .foregroundStyle(Color.Gray.gray400) + } + } } } diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_scare.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_scare.imageset/Contents.json new file mode 100644 index 00000000..8f5e50a3 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_scare.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "illust_scare.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_scare.imageset/illust_scare.svg b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_scare.imageset/illust_scare.svg new file mode 100644 index 00000000..132f5b4a --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_scare.imageset/illust_scare.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_trash.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_trash.imageset/Contents.json new file mode 100644 index 00000000..5e0a141b --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_trash.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "illust_trash.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_trash.imageset/illust_trash.svg b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_trash.imageset/illust_trash.svg new file mode 100644 index 00000000..34324c1c --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Image/ImageAssets.xcassets/Illustration/illust_trash.imageset/illust_trash.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift index 6deb2137..e2c38007 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/BottomSheet/TXCalendarBottomSheet.swift @@ -96,6 +96,7 @@ public struct TXCalendarBottomSheet: View { } else { TXCalendar( mode: .monthly, + currentDate: $selectedDate, weeks: displayWeeks, config: calendarConfig ) { item in @@ -105,6 +106,7 @@ public struct TXCalendarBottomSheet: View { } } } + .padding(.top, Spacing.spacing10) .padding(.bottom, 40) // 버튼 영역 diff --git a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift index 68f3f0fa..a9277368 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Calendar/Core/TXCalendar.swift @@ -24,8 +24,8 @@ public struct TXCalendar: View { case weekly case monthly } - - /// 주간 모드에서의 스와이프 방향입니다. + + /// 캘린더 스와이프 방향입니다. public enum SwipeGesture { case previous case next @@ -78,25 +78,57 @@ public struct TXCalendar: View { private let mode: DisplayMode private let weekdays: [String] private let weeks: [[TXCalendarDateItem]] + private let currentDate: Binding? + private let canMovePrevious: Bool + private let canMoveNext: Bool private let config: Configuration private let onSelect: (TXCalendarDateItem) -> Void - private let onWeekSwipe: ((SwipeGesture) -> Void)? + private let onSwipe: ((SwipeGesture) -> Void)? /// 캘린더 컴포넌트를 생성합니다. public init( mode: DisplayMode, weeks: [[TXCalendarDateItem]], weekdays: [String] = Self.defaultWeekdays, + canMovePrevious: Bool = true, + canMoveNext: Bool = true, + config: Configuration = .init(), + onSelect: @escaping (TXCalendarDateItem) -> Void = { _ in }, + onSwipe: ((SwipeGesture) -> Void)? = nil + + ) { + self.mode = mode + self.weeks = weeks + self.weekdays = Array(weekdays.prefix(TXCalendarLayout.daysInWeek)) + self.config = config + self.currentDate = nil + self.canMovePrevious = canMovePrevious + self.canMoveNext = canMoveNext + self.onSelect = onSelect + self.onSwipe = onSwipe + } + + /// 현재 날짜 바인딩을 포함한 캘린더 컴포넌트를 생성합니다. + public init( + mode: DisplayMode, + currentDate: Binding, + weeks: [[TXCalendarDateItem]], + weekdays: [String] = Self.defaultWeekdays, config: Configuration = .init(), + canMovePrevious: Bool = true, + canMoveNext: Bool = true, onSelect: @escaping (TXCalendarDateItem) -> Void = { _ in }, - onWeekSwipe: ((SwipeGesture) -> Void)? = nil + onSwipe: ((SwipeGesture) -> Void)? = nil ) { self.mode = mode self.weeks = weeks self.weekdays = Array(weekdays.prefix(TXCalendarLayout.daysInWeek)) self.config = config + self.currentDate = currentDate + self.canMovePrevious = canMovePrevious + self.canMoveNext = canMoveNext self.onSelect = onSelect - self.onWeekSwipe = onWeekSwipe + self.onSwipe = onSwipe } public var body: some View { @@ -120,14 +152,32 @@ public struct TXCalendar: View { .gesture( DragGesture(minimumDistance: 16) .onEnded { value in - guard mode == .weekly else { return } - let horizontalDistance = value.translation.width let verticalDistance = value.translation.height guard abs(horizontalDistance) > abs(verticalDistance) else { return } - let swipeGesture: SwipeGesture = horizontalDistance > 0 ? .previous : .next - onWeekSwipe?(swipeGesture) + let rightSwipe = horizontalDistance > 0 + if rightSwipe { + guard canMovePrevious else { return } + switch mode { + case .weekly: + applySwipeToCurrentDate(.previous) + onSwipe?(.previous) + case .monthly: + applySwipeToCurrentDate(.previous) + onSwipe?(.previous) + } + } else { + guard canMoveNext else { return } + switch mode { + case .weekly: + applySwipeToCurrentDate(.next) + onSwipe?(.next) + case .monthly: + applySwipeToCurrentDate(.next) + onSwipe?(.next) + } + } } ) } @@ -252,6 +302,30 @@ private extension TXCalendar { // MARK: - Private Methods private extension TXCalendar { + func applySwipeToCurrentDate(_ swipe: SwipeGesture) { + guard let currentDate else { return } + + var updatedDate = currentDate.wrappedValue + switch mode { + case .weekly: + let offset: Int + switch swipe { + case .previous: offset = -1 + case .next: offset = 1 + } + guard let date = TXCalendarUtil.dateByAddingWeek(from: updatedDate, by: offset) else { return } + updatedDate = date + + case .monthly: + switch swipe { + case .previous: updatedDate.goToPreviousMonth() + case .next: updatedDate.goToNextMonth() + } + } + + currentDate.wrappedValue = updatedDate + } + func weeklyHeaderTitle(index: Int, item: TXCalendarDateItem) -> String { guard let components = item.dateComponents, let year = components.year, diff --git a/Projects/Shared/DesignSystem/Sources/Components/TextField/TXCommentCircle.swift b/Projects/Shared/DesignSystem/Sources/Components/TextField/TXCommentCircle.swift index 1490ec12..df1fa26b 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/TextField/TXCommentCircle.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/TextField/TXCommentCircle.swift @@ -102,7 +102,12 @@ private extension TXCommentCircle { .foregroundColor( commentText.isEmpty ? Color.Gray.gray200 : Color.Gray.gray500 ) - .frame(width: Constants.circleSize, height: Constants.circleSize) + .overlay { + if isFocused && index == commentText.count { + cursor + } + } + .frame(width: Constants.circleSize, height: Constants.circleSize) } } .background { @@ -116,11 +121,23 @@ private extension TXCommentCircle { func circleText(at index: Int) -> String { let textArray = Array(commentText) if textArray.isEmpty { - return String(Constants.placeholder[index]) + return isFocused ? "" : String(Constants.placeholder[index]) } return index < textArray.count ? String(textArray[index]) : "" } + + var cursor: some View { + TimelineView(.periodic(from: .now, by: 0.5)) { context in + let isVisible = Int(context.date.timeIntervalSinceReferenceDate * 2).isMultiple(of: 2) + + RoundedRectangle(cornerRadius: 1) + .fill(Color.Gray.gray500) + .frame(width: 2, height: 28) + .opacity(isVisible ? 1 : 0) + } + .allowsHitTesting(false) + } } private enum Constants { diff --git a/Projects/Shared/DesignSystem/Sources/Resources/Image/Images.swift b/Projects/Shared/DesignSystem/Sources/Resources/Image/Images.swift index 0c468ab4..45389b96 100644 --- a/Projects/Shared/DesignSystem/Sources/Resources/Image/Images.swift +++ b/Projects/Shared/DesignSystem/Sources/Resources/Image/Images.swift @@ -115,4 +115,6 @@ public extension Image.Illustration { static let arrow = IllustrationAsset.illustArrow.swiftUIImage static let hug = IllustrationAsset.illustHug.swiftUIImage static let plane = IllustrationAsset.illustPlane.swiftUIImage + static let scare = IllustrationAsset.illustScare.swiftUIImage + static let trash = IllustrationAsset.illustTrash.swiftUIImage } diff --git a/Projects/Shared/Util/Sources/ImageUploadOptimizer.swift b/Projects/Shared/Util/Sources/ImageUploadOptimizer.swift new file mode 100644 index 00000000..b65d7930 --- /dev/null +++ b/Projects/Shared/Util/Sources/ImageUploadOptimizer.swift @@ -0,0 +1,61 @@ +import CoreGraphics +import Foundation +import ImageIO +import UIKit + +/// 업로드 전 이미지 데이터를 리사이징 및 JPEG 압축하여 전송 크기를 줄이는 유틸리티입니다. +public enum ImageUploadOptimizer { + /// 원본 이미지 데이터를 업로드용 JPEG 데이터로 최적화합니다. + /// + /// 긴 변 기준으로 이미지를 다운샘플링한 뒤 JPEG 압축을 적용합니다. + /// 다운샘플링 또는 인코딩에 실패하면 원본 데이터를 반환합니다. + /// + /// - Parameters: + /// - data: 원본 이미지 데이터입니다. + /// - maxLongEdge: 다운샘플링 시 허용할 긴 변의 최대 픽셀 크기입니다. 기본값은 `1920`입니다. + /// - compressionQuality: JPEG 압축 품질입니다. `0.0...1.0` 범위를 사용하며 기본값은 `0.85`입니다. + /// - Returns: 최적화된 JPEG 데이터 또는 실패 시 원본 데이터입니다. + /// + /// ## 사용 예시 + /// ```swift + /// let optimizedData = ImageUploadOptimizer.optimizedJPEGData( + /// from: imageData, + /// maxLongEdge: 1600, + /// compressionQuality: 0.8 + /// ) + /// ``` + public static func optimizedJPEGData( + from data: Data, + maxLongEdge: CGFloat = 1920, + compressionQuality: CGFloat = 0.85 + ) -> Data { + guard let downsampledImage = downsampledImage(from: data, maxPixelSize: maxLongEdge) else { + return data + } + + return downsampledImage.jpegData(compressionQuality: compressionQuality) ?? data + } + + private static func downsampledImage(from data: Data, maxPixelSize: CGFloat) -> UIImage? { + let sourceOptions: CFDictionary = [ + kCGImageSourceShouldCache: false + ] as CFDictionary + + guard let imageSource = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { + return nil + } + + let downsampleOptions: CFDictionary = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, // respects EXIF orientation + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize + ] as CFDictionary + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else { + return nil + } + + return UIImage(cgImage: cgImage) + } +}