diff --git a/Projects/App/Sources/MainTab/MainTab.swift b/Projects/App/Sources/MainTab/MainTab.swift index 458aa906..b82eb0c5 100644 --- a/Projects/App/Sources/MainTab/MainTab.swift +++ b/Projects/App/Sources/MainTab/MainTab.swift @@ -11,14 +11,14 @@ import DSKit public enum MainTab: String, CaseIterable { case pokit = "포킷" - case remind = "리마인드" + case recommend = "링크추천" var title: String { return self.rawValue } var icon: PokitImage { switch self { case .pokit: return .icon(.folderFill) - case .remind: return .icon(.remind) + case .recommend: return .icon(.remind) } } } diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift index f0684502..a297d2fb 100644 --- a/Projects/App/Sources/MainTab/MainTabFeature.swift +++ b/Projects/App/Sources/MainTab/MainTabFeature.swift @@ -8,7 +8,7 @@ import SwiftUI import ComposableArchitecture import FeaturePokit -import FeatureRemind +import FeatureRecommend import FeatureContentDetail import Domain import DSKit @@ -37,7 +37,7 @@ public struct MainTabFeature { var path: StackState = .init() var pokit: PokitRootFeature.State - var remind: RemindFeature.State = .init() + var recommend: RecommendFeature.State = .init() @Presents var contentDetail: ContentDetailFeature.State? @Shared(.inMemory("SelectCategory")) var categoryId: Int? @Shared(.inMemory("PushTapped")) var isPushTapped: Bool = false @@ -59,7 +59,7 @@ public struct MainTabFeature { /// Todo: scope로 이동 case path(StackAction) case pokit(PokitRootFeature.Action) - case remind(RemindFeature.Action) + case recommend(RecommendFeature.Action) case contentDetail(PresentationAction) @CasePathable @@ -70,6 +70,8 @@ public struct MainTabFeature { case onAppear case onOpenURL(url: URL) case 경고_확인버튼_클릭 + case 검색_버튼_눌렀을때 + case 알림_버튼_눌렀을때 } public enum InnerAction: Equatable { case 링크추가및수정이동(contentId: Int) @@ -129,7 +131,7 @@ public struct MainTabFeature { return .none case .pokit: return .none - case .remind: + case .recommend: return .none case .contentDetail: return .none @@ -138,7 +140,14 @@ public struct MainTabFeature { /// - Reducer body public var body: some ReducerOf { Scope(state: \.pokit, action: \.pokit) { PokitRootFeature() } - Scope(state: \.remind, action: \.remind) { RemindFeature() } + Scope(state: \.recommend, action: \.recommend) { + withDependencies { + $0[UserClient.self] = .testValue + $0[ContentClient.self] = .testValue + } operation: { + RecommendFeature() + } + } BindingReducer() navigationReducer @@ -198,6 +207,28 @@ private extension MainTabFeature { case .경고_확인버튼_클릭: state.error = nil return .run { send in await send(.inner(.errorSheetPresented(false))) } + case .검색_버튼_눌렀을때: + switch state.selectedTab { + case .pokit: return .none + case .recommend: + return RecommendFeature() + .reduce( + into: &state.recommend, + action: .view(.검색_버튼_눌렀을때) + ) + .map(Action.recommend) + } + case .알림_버튼_눌렀을때: + switch state.selectedTab { + case .pokit: return .none + case .recommend: + return RecommendFeature() + .reduce( + into: &state.recommend, + action: .view(.알림_버튼_눌렀을때) + ) + .map(Action.recommend) + } } } /// - Inner Effect diff --git a/Projects/App/Sources/MainTab/MainTabFeatureView.swift b/Projects/App/Sources/MainTab/MainTabFeatureView.swift index 3e695dd4..800285b6 100644 --- a/Projects/App/Sources/MainTab/MainTabFeatureView.swift +++ b/Projects/App/Sources/MainTab/MainTabFeatureView.swift @@ -9,7 +9,7 @@ import SwiftUI import ComposableArchitecture import DSKit import FeaturePokit -import FeatureRemind +import FeatureRecommend import FeatureSetting import FeatureCategorySetting import FeatureContentDetail @@ -135,8 +135,8 @@ private extension MainTabView { .pokitNavigationBar { pokitNavigationBar } .toolbarBackground(.hidden, for: .tabBar) - case .remind: - RemindView(store: store.scope(state: \.remind, action: \.remind)) + case .recommend: + RecommendView(store: store.scope(state: \.recommend, action: \.recommend)) .pokitNavigationBar { remindNavigationBar } .toolbarBackground(.hidden, for: .tabBar) } @@ -173,19 +173,19 @@ private extension MainTabView { var remindNavigationBar: some View { PokitHeader { PokitHeaderItems(placement: .leading) { - Text("Remind") - .font(.system(size: 32, weight: .heavy)) - .foregroundStyle(.pokit(.text(.brand))) + Text("링크추천") + .pokitFont(.title2) + .foregroundStyle(.pokit(.text(.primary))) } PokitHeaderItems(placement: .trailing) { PokitToolbarButton( .icon(.search), - action: { store.send(.remind(.view(.검색_버튼_눌렀을때))) } + action: { send(.검색_버튼_눌렀을때) } ) PokitToolbarButton( .icon(.bell), - action: { store.send(.remind(.view(.알림_버튼_눌렀을때))) } + action: { send(.알림_버튼_눌렀을때) } ) } } @@ -198,7 +198,7 @@ private extension MainTabView { Spacer() - bottomTabBarItem(.remind) + bottomTabBarItem(.recommend) } .padding(.horizontal, 48) .padding(.top, 12) diff --git a/Projects/App/Sources/MainTab/MainTabPath.swift b/Projects/App/Sources/MainTab/MainTabPath.swift index 11fba4db..756fb5a0 100644 --- a/Projects/App/Sources/MainTab/MainTabPath.swift +++ b/Projects/App/Sources/MainTab/MainTabPath.swift @@ -61,7 +61,7 @@ public extension MainTabFeature { switch action { /// - 네비게이션 바 `알림`버튼 눌렀을 때 case .pokit(.delegate(.alertButtonTapped)), - .remind(.delegate(.alertButtonTapped)), + .recommend(.delegate(.알림_버튼_눌렀을때)), .delegate(.알림함이동): state.isPushTapped = false state.path.append(.알림함(PokitAlertBoxFeature.State())) @@ -69,7 +69,7 @@ public extension MainTabFeature { /// - 네비게이션 바 `검색`버튼 눌렀을 때 case .pokit(.delegate(.searchButtonTapped)), - .remind(.delegate(.searchButtonTapped)): + .recommend(.delegate(.검색_버튼_눌렀을때)): state.path.append(.검색(PokitSearchFeature.State())) return .none @@ -120,7 +120,6 @@ public extension MainTabFeature { /// - 링크 상세 case let .path(.element(_, action: .카테고리상세(.delegate(.contentItemTapped(content))))), let .pokit(.delegate(.contentDetailTapped(content))), - let .remind(.delegate(.링크상세(content))), let .path(.element(_, action: .링크목록(.delegate(.링크상세(content: content))))), let .path(.element(_, action: .검색(.delegate(.linkCardTapped(content: content))))): @@ -130,7 +129,7 @@ public extension MainTabFeature { /// - 링크상세 바텀시트에서 링크수정으로 이동 case let .contentDetail(.presented(.delegate(.editButtonTapped(id)))), let .pokit(.delegate(.링크수정하기(id))), - let .remind(.delegate(.링크수정(id))), + let .recommend(.delegate(.추가하기_버튼_눌렀을때(id))), let .path(.element(_, action: .카테고리상세(.delegate(.링크수정(id))))), let .path(.element(_, action: .링크목록(.delegate(.링크수정(id))))), let .path(.element(_, action: .검색(.delegate(.링크수정(id))))), @@ -148,8 +147,8 @@ public extension MainTabFeature { switch state.selectedTab { case .pokit: return .send(.pokit(.delegate(.미분류_카테고리_컨텐츠_조회))) - case .remind: - return .send(.remind(.delegate(.컨텐츠_상세보기_delegate_위임))) + case .recommend: + return .none } } switch lastPath { @@ -200,14 +199,6 @@ public extension MainTabFeature { case let .pokit(.delegate(.linkPopup(text))): state.linkPopup = .text(title: text) return .none - /// 링크목록 `안읽음` - case .remind(.delegate(.링크목록_안읽음)): - state.path.append(.링크목록(ContentListFeature.State(contentType: .unread))) - return .none - /// 링크목록 `즐겨찾기` - case .remind(.delegate(.링크목록_즐겨찾기)): - state.path.append(.링크목록(ContentListFeature.State(contentType: .favorite))) - return .none case .path(.element(_, action: .설정(.delegate(.로그아웃)))): return .send(.delegate(.로그아웃)) diff --git a/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift index 2568b90a..ea8d594f 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift @@ -18,6 +18,7 @@ public struct ContentBaseResponse: Decodable { public let createdAt: String public let isRead: Bool? public let isFavorite: Bool? + public let keyword: String? } extension ContentBaseResponse { @@ -25,17 +26,18 @@ extension ContentBaseResponse { Self( contentId: id, category: .init( - categoryId: 992, - categoryName: "미분류" + categoryId: 567, + categoryName: "신서유기" ), - data: "https://www.youtube.com/watch?v=wtSwdGJzQCQ", + data: "https://youtu.be/CIzKDrN7IpU?si=B0-7X7I_54VHAfkk", domain: "youtube", - title: "신서유기", + title: "[#샷추가] 거리 두기 철저하게 지키게 만드는 인물 퀴즈ㅋㅋㅋ어떤 음식을 뺄지 고민하지 마요..어차피 다 못 먹으니까요🤣 | #신서유기5 #Diggle", memo: nil, - thumbNail: "https://i.ytimg.com/vi/NnOC4_kH0ok/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDN6u6mTjbaVmRZ4biJS_aDq4uvAQ", - createdAt: "2024.08.08", + thumbNail: "https://i.ytimg.com/vi/CIzKDrN7IpU/maxresdefault.jpg", + createdAt: "2024.12.03", isRead: false, - isFavorite: true + isFavorite: true, + keyword: "예능" ) } } diff --git a/Projects/CoreKit/Sources/Data/DTO/User/InterestResponse.swift b/Projects/CoreKit/Sources/Data/DTO/User/InterestResponse.swift index 629cda7f..58d0628e 100644 --- a/Projects/CoreKit/Sources/Data/DTO/User/InterestResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/User/InterestResponse.swift @@ -23,6 +23,14 @@ extension InterestResponse { Self(code: "code2", description: "산책"), Self(code: "code3", description: "프로그래밍"), Self(code: "code4", description: "여행"), - Self(code: "code5", description: "요리") + Self(code: "code5", description: "요리"), + Self(code: "code6", description: "스포츠/레저"), + Self(code: "code7", description: "기획/마케팅"), + Self(code: "code8", description: "쇼핑"), + Self(code: "code9", description: "경제/시사"), + Self(code: "code10", description: "영화/드라마"), + Self(code: "code11", description: "장소"), + Self(code: "code12", description: "인테리어"), + Self(code: "code13", description: "IT"), ] } diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift index a506f1de..873d4bb2 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift @@ -61,6 +61,9 @@ extension ContentClient: DependencyKey { }, 미분류_링크_삭제: { model in try await provider.requestNoBody(.미분류_링크_삭제(model: model)) + }, + 추천_컨텐츠_조회: { pageable, keyword in + try await provider.request(.추천_컨텐츠_조회(pageable: pageable, keyword: keyword)) } ) }() diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift index f9943f3e..66df7905 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift @@ -20,7 +20,8 @@ extension ContentClient: TestDependencyKey { 컨텐츠_검색: { _, _ in .mock }, 썸네일_수정: { _, _ in }, 미분류_링크_포킷_이동: { _ in }, - 미분류_링크_삭제: { _ in } + 미분류_링크_삭제: { _ in }, + 추천_컨텐츠_조회: { _, _ in .mock } ) }() } diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift index 9c65dc5a..3e11dbed 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift @@ -50,5 +50,9 @@ public struct ContentClient { public var 미분류_링크_삭제: @Sendable ( _ model: ContentDeleteRequest ) async throws -> Void + public var 추천_컨텐츠_조회: @Sendable ( + _ pageable: BasePageableRequest, + _ keyword: String? + ) async throws -> ContentListInquiryResponse } diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift index 739aa56a..25e0cf60 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift @@ -30,6 +30,10 @@ public enum ContentEndpoint { case 썸네일_수정(contentId: String, model: ThumbnailRequest) case 미분류_링크_포킷_이동(model: ContentMoveRequest) case 미분류_링크_삭제(model: ContentDeleteRequest) + case 추천_컨텐츠_조회( + pageable: BasePageableRequest, + keyword: String? + ) } extension ContentEndpoint: TargetType { @@ -63,6 +67,8 @@ extension ContentEndpoint: TargetType { return "" case .미분류_링크_삭제: return "/uncategorized" + case .추천_컨텐츠_조회: + return "/recommended" } } @@ -85,7 +91,8 @@ extension ContentEndpoint: TargetType { case .카태고리_내_컨텐츠_목록_조회, .미분류_카테고리_컨텐츠_조회, - .컨텐츠_검색: + .컨텐츠_검색, + .추천_컨텐츠_조회: return .get } } @@ -126,6 +133,19 @@ extension ContentEndpoint: TargetType { ], encoding: URLEncoding.default ) + case let .추천_컨텐츠_조회(pageable, keyword): + var parameters: [String: Any] = [ + "page": pageable.page, + "size": pageable.size, + "sort": pageable.sort.map { String($0) }.joined(separator: ",") + ] + if let keyword { + parameters["keyword"] = keyword + } + return .requestParameters( + parameters: parameters, + encoding: URLEncoding.default + ) case let .컨텐츠_검색(pageable, condition): return .requestParameters( parameters: [ diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift index a268c605..cd4d986f 100644 --- a/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift @@ -32,6 +32,9 @@ extension UserClient: DependencyKey { }, fcm_토큰_저장: { model in try await provider.request(.fcm_토큰_저장(model: model)) + }, + 유저_관심사_목록_조회: { + try await provider.request(.유저_관심사_목록_조회) } ) }() diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift index 2f3d37c8..8cb389bd 100644 --- a/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift @@ -17,7 +17,8 @@ extension UserClient: TestDependencyKey { 닉네임_중복_체크: { _ in .mock }, 관심사_목록_조회: { InterestResponse.mock }, 닉네임_조회: { .mock }, - fcm_토큰_저장: { _ in .mock } + fcm_토큰_저장: { _ in .mock }, + 유저_관심사_목록_조회: { InterestResponse.mock } ) }() } diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift index 7ccb2149..1dae4848 100644 --- a/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift @@ -15,4 +15,5 @@ public struct UserClient { public var 관심사_목록_조회: @Sendable () async throws -> [InterestResponse] public var 닉네임_조회: @Sendable () async throws -> BaseUserResponse public var fcm_토큰_저장: @Sendable (_ model: FCMRequest) async throws -> FCMResponse + public var 유저_관심사_목록_조회: @Sendable () async throws -> [InterestResponse] } diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserEndpoint.swift b/Projects/CoreKit/Sources/Data/Network/User/UserEndpoint.swift index d928fb2b..a7fa6d2f 100644 --- a/Projects/CoreKit/Sources/Data/Network/User/UserEndpoint.swift +++ b/Projects/CoreKit/Sources/Data/Network/User/UserEndpoint.swift @@ -17,6 +17,7 @@ public enum UserEndpoint { case 관심사_목록_조회 case 닉네임_조회 case fcm_토큰_저장(model: FCMRequest) + case 유저_관심사_목록_조회 } extension UserEndpoint: TargetType { @@ -36,6 +37,8 @@ extension UserEndpoint: TargetType { return "/interests" case .fcm_토큰_저장: return "/fcm" + case .유저_관심사_목록_조회: + return "/myinterests" } } @@ -50,7 +53,8 @@ extension UserEndpoint: TargetType { case .닉네임_중복_체크, .관심사_목록_조회, - .닉네임_조회: + .닉네임_조회, + .유저_관심사_목록_조회: return .get } } @@ -65,7 +69,8 @@ extension UserEndpoint: TargetType { return .requestJSONEncodable(model) case .닉네임_중복_체크, .관심사_목록_조회, - .닉네임_조회: + .닉네임_조회, + .유저_관심사_목록_조회: return .requestPlain } } diff --git a/Projects/DSKit/Sources/Components/PokitFlowLayout.swift b/Projects/DSKit/Sources/Components/PokitFlowLayout.swift index 98a7042a..e69a454f 100644 --- a/Projects/DSKit/Sources/Components/PokitFlowLayout.swift +++ b/Projects/DSKit/Sources/Components/PokitFlowLayout.swift @@ -20,44 +20,64 @@ public struct PokitFlowLayout: Layout { } public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - var width: CGFloat = 0 - var height: CGFloat = 0 + var totalWidth: CGFloat = 0 + var totalHeight: CGFloat = 0 var rowWidth: CGFloat = 0 var rowHeight: CGFloat = 0 - - for subview in subviews { - let size = subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) - if rowWidth + size.width > proposal.width ?? .infinity { - height += rowHeight + rowSpacing - width = max(width, rowWidth) - rowWidth = 0 - rowHeight = 0 + let maxWidth = proposal.width ?? CGFloat.infinity + + for (_, subview) in subviews.enumerated() { + let subviewSize = subview.sizeThatFits(ProposedViewSize(width: maxWidth, height: nil)) + let itemWidth = subviewSize.width + let itemHeight = subviewSize.height + + if rowWidth > 0 && (rowWidth + colSpacing + itemWidth) > maxWidth { + // 현재 행 마무리 + totalWidth = max(totalWidth, rowWidth) + totalHeight += rowHeight + rowSpacing + // 새로운 행 시작 + rowWidth = itemWidth + rowHeight = itemHeight + } else { + if rowWidth > 0 { + rowWidth += colSpacing + } + rowWidth += itemWidth + rowHeight = max(rowHeight, itemHeight) } - rowWidth += size.width + colSpacing - rowHeight = max(rowHeight, size.height) } - - height += rowHeight - width = max(width, rowWidth) - - return CGSize(width: width, height: height) + + // 마지막 행 높이 추가 + totalWidth = max(totalWidth, rowWidth) + totalHeight += rowHeight + rowSpacing + + return CGSize(width: totalWidth, height: totalHeight) } - + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - var x: CGFloat = bounds.minX - var y: CGFloat = bounds.minY + var x = bounds.minX + var y = bounds.minY var rowHeight: CGFloat = 0 - - for subview in subviews { - let size = subview.sizeThatFits(ProposedViewSize(width: bounds.width, height: nil)) - if x + size.width > bounds.width { + let maxX = bounds.maxX + + for (_, subview) in subviews.enumerated() { + let subviewSize = subview.sizeThatFits(ProposedViewSize(width: bounds.width, height: nil)) + let itemWidth = subviewSize.width + let itemHeight = subviewSize.height + + if x > bounds.minX - 1 && (x + colSpacing + itemWidth) > maxX + 1 { + // 현재 행 마무리하고 다음 행 시작 x = bounds.minX y += rowHeight + rowSpacing rowHeight = 0 } - subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) - x += size.width + colSpacing - rowHeight = max(rowHeight, size.height) + if x > bounds.minX { + x += colSpacing + } + // 아이템 배치 + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(subviewSize)) + x += itemWidth + rowHeight = max(rowHeight, itemHeight) } } } diff --git a/Projects/DSKit/Sources/Components/PokitTextButton.swift b/Projects/DSKit/Sources/Components/PokitTextButton.swift index 286ff6f0..0a58ffe4 100644 --- a/Projects/DSKit/Sources/Components/PokitTextButton.swift +++ b/Projects/DSKit/Sources/Components/PokitTextButton.swift @@ -41,6 +41,7 @@ public struct PokitTextButton: View { .foregroundStyle(self.state.textColor) .padding(.horizontal, self.size.hPadding) .padding(.vertical, self.size.vPadding) + .frame(minWidth: self.size.minWidth) .background { RoundedRectangle(cornerRadius: shape.radius(size: self.size), style: .continuous) .fill(self.state.backgroundColor) @@ -49,6 +50,5 @@ public struct PokitTextButton: View { .stroke(self.state.backgroundStrokeColor, lineWidth: 1) } } - .frame(minWidth: self.size.minWidth) } } diff --git a/Projects/DSKit/Sources/Foundation/PokitButtonStyle.swift b/Projects/DSKit/Sources/Foundation/PokitButtonStyle.swift index 3c0e7225..e20e0fc8 100644 --- a/Projects/DSKit/Sources/Foundation/PokitButtonStyle.swift +++ b/Projects/DSKit/Sources/Foundation/PokitButtonStyle.swift @@ -13,6 +13,7 @@ public enum PokitButtonStyle { case stroke(PokitButtonStyle.ButtonType) case filled(PokitButtonStyle.ButtonType) case disable + case opacity } public enum ButtonType: Equatable { @@ -43,6 +44,12 @@ extension PokitButtonStyle.State { } case .disable: return .pokit(.bg(.disable)) + case .opacity: + return Color( + red: 67 / 255, + green: 67 / 255, + blue: 67 / 255 + ).opacity(0.4) } } @@ -56,6 +63,7 @@ extension PokitButtonStyle.State { } case .disable: return .pokit(.border(.disable)) + case .opacity: return .clear } } @@ -63,7 +71,7 @@ extension PokitButtonStyle.State { switch self { case .default: return .pokit(.icon(.disable)) case .stroke(_): return .pokit(.icon(.primary)) - case .filled(_): return .pokit(.icon(.inverseWh)) + case .filled(_), .opacity: return .pokit(.icon(.inverseWh)) case .disable: return .pokit(.icon(.disable)) } } @@ -72,7 +80,7 @@ extension PokitButtonStyle.State { switch self { case .default: return .pokit(.text(.tertiary)) case .stroke(_): return .pokit(.text(.primary)) - case .filled(_): return .pokit(.text(.inverseWh)) + case .filled(_), .opacity: return .pokit(.text(.inverseWh)) case .disable: return .pokit(.text(.disable)) } } diff --git a/Projects/Domain/Sources/Base/BaseContentItem.swift b/Projects/Domain/Sources/Base/BaseContentItem.swift index d630964d..27ebd387 100644 --- a/Projects/Domain/Sources/Base/BaseContentItem.swift +++ b/Projects/Domain/Sources/Base/BaseContentItem.swift @@ -21,6 +21,7 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta public let createdAt: String public let isRead: Bool? public var isFavorite: Bool? + public let keyword: String? public init( id: Int, @@ -33,7 +34,8 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta domain: String, createdAt: String, isRead: Bool?, - isFavorite: Bool? + isFavorite: Bool?, + keyword: String? = nil ) { self.id = id self.categoryName = categoryName @@ -46,5 +48,6 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta self.createdAt = createdAt self.isRead = isRead self.isFavorite = isFavorite + self.keyword = keyword } } diff --git a/Projects/Domain/Sources/Base/BaseInterest.swift b/Projects/Domain/Sources/Base/BaseInterest.swift new file mode 100644 index 00000000..ea327553 --- /dev/null +++ b/Projects/Domain/Sources/Base/BaseInterest.swift @@ -0,0 +1,19 @@ +// +// BaseInterest.swift +// Domain +// +// Created by 김도형 on 1/29/25. +// + +import Foundation + +public struct BaseInterest: Equatable, Identifiable { + public let id = UUID() + public let code: String + public let description: String + + public init(code: String, description: String) { + self.code = code + self.description = description + } +} diff --git a/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift b/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift index e67218ed..00c3622e 100644 --- a/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift +++ b/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift @@ -23,7 +23,8 @@ public extension ContentBaseResponse { domain: self.domain, createdAt: self.createdAt, isRead: self.isRead, - isFavorite: self.isFavorite + isFavorite: self.isFavorite, + keyword: self.keyword ) } } diff --git a/Projects/Domain/Sources/DTO/Base/BaseInterest+Extension.swift b/Projects/Domain/Sources/DTO/Base/BaseInterest+Extension.swift new file mode 100644 index 00000000..20b4f677 --- /dev/null +++ b/Projects/Domain/Sources/DTO/Base/BaseInterest+Extension.swift @@ -0,0 +1,19 @@ +// +// BaseInterest+Extension.swift +// Domain +// +// Created by 김도형 on 1/29/25. +// + +import Foundation + +import CoreKit + +public extension InterestResponse { + func toDomian() -> BaseInterest { + return BaseInterest( + code: self.code, + description: self.description + ) + } +} diff --git a/Projects/Domain/Sources/Recommend/Recommend.swift b/Projects/Domain/Sources/Recommend/Recommend.swift new file mode 100644 index 00000000..e31a41a1 --- /dev/null +++ b/Projects/Domain/Sources/Recommend/Recommend.swift @@ -0,0 +1,30 @@ +// +// Recommend.swift +// Domain +// +// Created by 김도형 on 1/29/25. +// + +import Foundation + +public struct Recommend: Equatable { + // - MARK: Response + /// 콘텐츠 목록 + public var contentList: BaseContentListInquiry + public var pageable: BasePageable + public var interests: [BaseInterest] + + public init() { + self.contentList = .init( + page: 0, + size: 0, + sort: [], + hasNext: false + ) + self.pageable = .init( + page: 0, size: 10, + sort: ["createdAt,desc"] + ) + self.interests = [] + } +} diff --git a/Projects/Feature/FeatureRecommend/Resources/.gitkeep b/Projects/Feature/FeatureRecommend/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift new file mode 100644 index 00000000..f65ea9d3 --- /dev/null +++ b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift @@ -0,0 +1,299 @@ +// +// RecommendFeature.swift +// Feature +// +// Created by 김도형 on 1/29/25. + +import SwiftUI + +import ComposableArchitecture +import Domain +import CoreKit +import Util + +@Reducer +public struct RecommendFeature { + /// - Dependency + @Dependency(ContentClient.self) + private var contentClient + @Dependency(UserClient.self) + private var userClient + @Dependency(\.openURL) + private var openURL + /// - State + @ObservableState + public struct State: Equatable { + public init() {} + + fileprivate var domain = Recommend() + var isListDescending = true + /// pagenation + var hasNext: Bool { + domain.contentList.hasNext + } + var recommendedList: IdentifiedArrayOf? { + guard let list = domain.contentList.data else { return nil } + var array = IdentifiedArrayOf() + array.append(contentsOf: list) + return array + } + var interestList: IdentifiedArrayOf { + var array = IdentifiedArrayOf() + array.append(contentsOf: domain.interests) + return array + } + var isLoading: Bool = true + var selectedInterest: BaseInterest? + var shareContent: BaseContentItem? + } + + /// - Action + public enum Action: FeatureAction, ViewAction { + case view(View) + case inner(InnerAction) + case async(AsyncAction) + case scope(ScopeAction) + case delegate(DelegateAction) + + @CasePathable + public enum View: BindableAction { + /// - Binding + case binding(BindingAction) + + case onAppear + case pagination + + case 추가하기_버튼_눌렀을때(BaseContentItem) + case 공유하기_버튼_눌렀을때(BaseContentItem) + case 신고하기_버튼_눌렀을때(BaseContentItem) + case 전체보기_버튼_눌렀을때(ScrollViewProxy) + case 관심사_버튼_눌렀을때(BaseInterest, ScrollViewProxy) + case 링크_공유_완료되었을때 + case 검색_버튼_눌렀을때 + case 알림_버튼_눌렀을때 + case 추천_컨텐츠_눌렀을때(String) + } + + public enum InnerAction: Equatable { + case 추천_조회_API_반영(BaseContentListInquiry) + case 추천_조회_페이징_API_반영(BaseContentListInquiry) + case 유저_관심사_조회_API_반영([BaseInterest]) + } + + public enum AsyncAction: Equatable { + case 추천_조회_API + case 추천_조회_페이징_API + case 유저_관심사_조회_API + } + + public enum ScopeAction: Equatable { case doNothing } + + public enum DelegateAction: Equatable { + case 추가하기_버튼_눌렀을때(Int) + case 검색_버튼_눌렀을때 + case 알림_버튼_눌렀을때 + } + } + + /// - Initiallizer + public init() {} + + /// - Reducer Core + private func core(into state: inout State, action: Action) -> Effect { + switch action { + /// - View + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + + /// - Inner + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + + /// - Async + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + + /// - Scope + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + + /// - Delegate + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + } + } + + /// - Reducer body + public var body: some ReducerOf { + BindingReducer(action: \.view) + + Reduce(self.core) + } +} +//MARK: - FeatureAction Effect +private extension RecommendFeature { + /// - View Effect + func handleViewAction(_ action: Action.View, state: inout State) -> Effect { + switch action { + case .binding: return .none + case .onAppear: + return .merge( + shared(.async(.추천_조회_API), state: &state), + shared(.async(.유저_관심사_조회_API), state: &state) + ) + case .pagination: + return shared(.async(.추천_조회_페이징_API), state: &state) + case let .추가하기_버튼_눌렀을때(content): + return .send(.delegate(.추가하기_버튼_눌렀을때(content.id))) + case let .공유하기_버튼_눌렀을때(content): + state.shareContent = content + return .none + case let .신고하기_버튼_눌렀을때(content): + return .none + case let .전체보기_버튼_눌렀을때(proxy): + guard state.selectedInterest != nil else { return .none } + + state.selectedInterest = nil + let leading = 20 / UIScreen.main.bounds.width + let anchor = UnitPoint( + x: leading, + y: UnitPoint.leading.y + ) + proxy.scrollTo("전체보기", anchor: anchor) + return shared(.async(.추천_조회_API), state: &state) + case let .관심사_버튼_눌렀을때(interest, proxy): + guard state.selectedInterest != interest else { return .none } + + state.selectedInterest = interest + let leading = 20 / UIScreen.main.bounds.width + let anchor = UnitPoint( + x: leading, + y: UnitPoint.leading.y + ) + proxy.scrollTo(interest.description, anchor: anchor) + return shared(.async(.추천_조회_API), state: &state) + case .링크_공유_완료되었을때: + state.shareContent = nil + return .none + case .검색_버튼_눌렀을때: + return .send(.delegate(.검색_버튼_눌렀을때)) + case .알림_버튼_눌렀을때: + return .send(.delegate(.알림_버튼_눌렀을때)) + case let .추천_컨텐츠_눌렀을때(urlString): + guard let url = URL(string: urlString) else { return .none } + return .run { _ in await openURL(url) } + } + } + + /// - Inner Effect + func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { + switch action { + case .추천_조회_페이징_API_반영(let contentList): + let list = state.domain.contentList.data ?? [] + guard let newList = contentList.data else { return .none } + + state.domain.contentList = contentList + state.domain.contentList.data = list + newList + return .none + case .추천_조회_API_반영(let contentList): + state.domain.contentList = contentList + + state.isLoading = false + return .none + case let .유저_관심사_조회_API_반영(interests): + state.domain.interests = interests + return .none + } + } + + /// - Async Effect + func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { + switch action { + case .추천_조회_페이징_API: + state.domain.pageable.page += 1 + return .run { [ + pageable = state.domain.pageable, + keyword = state.selectedInterest?.description + ] send in + let pageableRequest = BasePageableRequest( + page: pageable.page, + size: pageable.size, + sort: pageable.sort + ) + let contentList = try await contentClient.추천_컨텐츠_조회( + pageableRequest, + keyword + ).toDomain() + + await send(.inner(.추천_조회_페이징_API_반영(contentList))) + } + case .추천_조회_API: + return contentListFetch(state: &state) + case .유저_관심사_조회_API: + return .run { send in + let interests = try await userClient.유저_관심사_목록_조회().map { $0.toDomian() } + await send(.inner(.유저_관심사_조회_API_반영(interests))) + } + } + } + + /// - Scope Effect + func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { + return .none + } + + /// - Delegate Effect + func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { + return .none + } + + /// - Shared Effect + func shared(_ action: Action, state: inout State) -> Effect { + switch action { + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + } + } + + func contentListFetch(state: inout State) -> Effect { + return .run { [ + pageable = state.domain.pageable, + keyword = state.selectedInterest?.description + ] send in + let stream = AsyncThrowingStream { continuation in + Task { + for page in 0...pageable.page { + let pageableRequest = BasePageableRequest( + page: page, + size: pageable.size, + sort: pageable.sort + ) + let contentList = try await contentClient.추천_컨텐츠_조회( + pageableRequest, + keyword + ).toDomain() + continuation.yield(contentList) + } + continuation.finish() + } + } + var contentItems: BaseContentListInquiry? = nil + for try await contentList in stream { + let items = contentItems?.data ?? [] + let newItems = contentList.data ?? [] + contentItems = contentList + contentItems?.data = items + newItems + } + guard let contentItems else { return } + await send(.inner(.추천_조회_API_반영(contentItems)), animation: .pokitDissolve) + } + } +} diff --git a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift new file mode 100644 index 00000000..3e7a892c --- /dev/null +++ b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift @@ -0,0 +1,292 @@ +// +// RecommendView.swift +// Feature +// +// Created by 김도형 on 1/29/25. + +import SwiftUI + +import ComposableArchitecture +import Domain +import DSKit +import NukeUI + +@ViewAction(for: RecommendFeature.self) +public struct RecommendView: View { + /// - Properties + @Perception.Bindable + public var store: StoreOf + + /// - Initializer + public init(store: StoreOf) { + self.store = store + } +} +//MARK: - View +public extension RecommendView { + var body: some View { + WithPerceptionTracking { + VStack(spacing: 10) { + interestList + + list + } + .ignoresSafeArea(edges: .bottom) + .sheet(item: $store.shareContent) { content in + if let shareURL = URL(string: content.data) { + PokitShareSheet( + items: [shareURL], + completion: { send(.링크_공유_완료되었을때) } + ) + .presentationDetents([.medium, .large]) + } + } + .task { await send(.onAppear).finish() } + } + } +} +//MARK: - Configure View +private extension RecommendView { + var interestList: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + interestListContent(proxy) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .padding(.trailing, 40) + } + .overlay(alignment: .trailing) { + interestEditButton + } + .padding(.bottom, 4) + } + } + + var interestEditButton: some View { + PokitIconButton( + .icon(.edit), + state: .default(.secondary), + size: .small, + shape: .round, + action: { } + ) + .padding([.leading, .vertical], 8) + .padding(.trailing, 20) + .background( + LinearGradient( + stops: [ + Gradient.Stop( + color: .pokit(.bg(.base)), + location: 0.00 + ), + Gradient.Stop( + color: .pokit(.bg(.base)).opacity(0), + location: 1.00 + ), + ], + startPoint: UnitPoint(x: 0.1, y: 0.52), + endPoint: UnitPoint(x: 0, y: 0.52) + ) + ) + } + + @ViewBuilder + func interestListContent(_ proxy: ScrollViewProxy) -> some View { + HStack(spacing: 8) { + let isAllSelected = store.selectedInterest == nil + + PokitTextButton( + "전체보기", + state: isAllSelected + ? .filled(.primary) + : .default(.secondary), + size: .small, + shape: .round + ) { + send( + .전체보기_버튼_눌렀을때(proxy), + animation: .pokitDissolve + ) + } + .id("전체보기") + + ForEach(store.interestList) { interest in + let isSelected = store.selectedInterest == interest + + PokitTextButton( + interest.description, + state: isSelected + ? .filled(.primary) + : .default(.secondary), + size: .small, + shape: .round + ) { + send( + .관심사_버튼_눌렀을때(interest, proxy), + animation: .pokitDissolve + ) + } + .id(interest.description) + } + } + } + + @ViewBuilder + var list: some View { + if let recommendedList = store.recommendedList { + if recommendedList.isEmpty { + empty + } else { + listContent(recommendedList) + } + } else { + PokitLoading() + } + } + + @ViewBuilder + var empty: some View { + PokitCaution(type: .링크없음) + .padding(.top, 100) + + Spacer() + } + + @ViewBuilder + func listContent( + _ recommendedList: IdentifiedArrayOf + ) -> some View { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(recommendedList) { content in + recommendedCard(content) + } + + if store.hasNext { + PokitLoading() + .task { await send(.pagination).finish() } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 150) + } + } + + @ViewBuilder + func recommendedCard(_ content: BaseContentItem) -> some View { + Button(action: { send(.추천_컨텐츠_눌렀을때(content.data)) }) { + recomendedCardLabel(content) + } + } + + @ViewBuilder + func recomendedCardLabel(_ content: BaseContentItem) -> some View { + VStack(alignment: .leading, spacing: 0) { + if let url = URL(string: content.thumbNail) { + recommendedImage(url: url) + } + + recommededTitle(content) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(.pokit(.bg(.base))) + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .inset(by: 0.5) + .stroke(.pokit(.border(.tertiary)), lineWidth: 1) + ) + .clipped() + .overlay(alignment: .topTrailing) { + recommendedCardButton(content) + .padding(12) + } + } + + @ViewBuilder + func recommendedCardButton(_ content: BaseContentItem) -> some View { + HStack(spacing: 6) { + PokitIconButton( + .icon(.plusR), + state: .opacity, + size: .small, + shape: .round, + action: { send(.추가하기_버튼_눌렀을때(content)) } + ) + + PokitIconButton( + .icon(.share), + state: .opacity, + size: .small, + shape: .round, + action: { send(.공유하기_버튼_눌렀을때(content)) } + ) + + PokitIconButton( + .icon(.report), + state: .opacity, + size: .small, + shape: .round, + action: { send(.신고하기_버튼_눌렀을때(content)) } + ) + } + } + + @ViewBuilder + func recommededTitle(_ content: BaseContentItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + if let keyword = content.keyword { + PokitBadge(state: .default(keyword)) + } + + PokitBadge(state: .default(content.domain)) + } + + Text(content.title) + .foregroundStyle(.pokit(.text(.primary))) + .pokitFont(.b3(.b)) + .multilineTextAlignment(.leading) + .lineLimit(2) + } + } + + @MainActor + @ViewBuilder + func recommendedImage(url: URL) -> some View { + LazyImage(url: url) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else if phase.error != nil { + imagePlaceholder + } else { + imagePlaceholder + } + } + .frame(height: 141) + } + + var imagePlaceholder: some View { + ZStack { + Color.pokit(.bg(.disable)) + + PokitSpinner() + .foregroundStyle(.pink) + .frame(width: 48, height: 48) + } + } +} +//MARK: - Preview +#Preview { + RecommendView( + store: Store( + initialState: .init(), + reducer: { RecommendFeature() } + ) + ) +} + + diff --git a/Projects/Feature/FeatureRemindDemo/Resources/LaunchScreen.storyboard b/Projects/Feature/FeatureRecommendDemo/Resources/LaunchScreen.storyboard similarity index 100% rename from Projects/Feature/FeatureRemindDemo/Resources/LaunchScreen.storyboard rename to Projects/Feature/FeatureRecommendDemo/Resources/LaunchScreen.storyboard diff --git a/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift b/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift new file mode 100644 index 00000000..2f92fe2a --- /dev/null +++ b/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift @@ -0,0 +1,35 @@ +// +// App.stencil.swift +// ProjectDescriptionHelpers +// +// Created by 김도형 on 6/16/24. +// + +import SwiftUI + +import FeatureRecommend +import FeatureIntro +import CoreKit + +@main +struct FeatureRecommendDemoApp: App { + var body: some Scene { + WindowGroup { + // TODO: 루트 뷰 추가 + + DemoView(store: .init( + initialState: .init(), + reducer: { DemoFeature() } + )) { + RecommendView(store: .init( + initialState: .init(), + reducer: { RecommendFeature()._printChanges() }, + withDependencies: { + $0[ContentClient.self] = .testValue + $0[UserClient.self] = .testValue + } + )) + } + } + } +} diff --git a/Projects/Feature/FeatureRemindTests/Resources/info.plist b/Projects/Feature/FeatureRecommendTests/Resources/info.plist similarity index 100% rename from Projects/Feature/FeatureRemindTests/Resources/info.plist rename to Projects/Feature/FeatureRecommendTests/Resources/info.plist diff --git a/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift b/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift new file mode 100644 index 00000000..a54bfab4 --- /dev/null +++ b/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureRecommend + +final class FeatureRecommendTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift b/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift deleted file mode 100644 index 4638db6b..00000000 --- a/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift +++ /dev/null @@ -1,309 +0,0 @@ -// -// RemindFeature.swift -// Feature -// -// Created by 김도형 on 7/12/24. - -import SwiftUI - -import ComposableArchitecture -import Domain -import CoreKit -import Util -import DSKit - -@Reducer -public struct RemindFeature { - /// - Dependency - @Dependency(\.dismiss) - private var dismiss - @Dependency(\.openURL) - private var openURL - @Dependency(RemindClient.self) - private var remindClient - @Dependency(ContentClient.self) - private var contentClient - @Dependency(SwiftSoupClient.self) - private var swiftSoupClient - /// - State - @ObservableState - public struct State: Equatable { - public init() {} - - fileprivate var domain = Remind() - var recommendedContents: IdentifiedArrayOf? { - guard let recommendedList = domain.recommendedList else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - recommendedList.forEach { identifiedArray.append($0) } - return identifiedArray - } - var unreadContents: IdentifiedArrayOf? { - guard let unreadList = domain.unreadList.data else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - unreadList.forEach { identifiedArray.append($0) } - return identifiedArray - } - var favoriteContents: IdentifiedArrayOf? { - guard let favoriteList = domain.favoriteList.data else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - favoriteList.forEach { identifiedArray.append($0) } - return identifiedArray - } - } - /// - Action - public enum Action: FeatureAction, ViewAction { - case view(View) - case inner(InnerAction) - case async(AsyncAction) - case scope(ScopeAction) - case delegate(DelegateAction) - - public enum View: Equatable, BindableAction { - case binding(BindingAction) - - /// - Button Tapped - case 알림_버튼_눌렀을때 - case 검색_버튼_눌렀을때 - case 컨텐츠_항목_눌렀을때(content: BaseContentItem) - case 컨텐츠_항목_케밥_버튼_눌렀을때(content: BaseContentItem) - case 안읽음_목록_버튼_눌렀을때 - case 즐겨찾기_목록_버튼_눌렀을때 - - case 뷰가_나타났을때 - case 즐겨찾기_항목_이미지_조회(contentId: Int) - case 읽지않음_항목_이미지_조회(contentId: Int) - case 리마인드_항목_이미지오류_나타났을때(contentId: Int) - } - public enum InnerAction: Equatable { - case 오늘의_리마인드_조회_API_반영(contents: [BaseContentItem]) - case 읽지않음_컨텐츠_조회_API_반영(contentList: BaseContentListInquiry) - case 즐겨찾기_링크모음_조회_API_반영(contentList: BaseContentListInquiry) - case 즐겨찾기_이미지_조회_수행_반영(imageURL: String, index: Int) - case 읽지않음_이미지_조회_수행_반영(imageURL: String, index: Int) - case 리마인드_이미지_조회_수행_반영(imageURL: String, index: Int) - - } - public enum AsyncAction: Equatable { - case 오늘의_리마인드_조회_API - case 읽지않음_컨텐츠_조회_API - case 즐겨찾기_링크모음_조회_API - case 썸네일_수정_API(imageURL: String, contentId: Int) - case 즐겨찾기_이미지_조회_수행(contentId: Int) - case 읽지않음_이미지_조회_수행(contentId: Int) - case 리마인드_이미지_조회_수행(contentId: Int) - } - public enum ScopeAction: Equatable { case 없음 } - public enum DelegateAction: Equatable { - case 링크상세(content: BaseContentItem) - case alertButtonTapped - case searchButtonTapped - case 링크수정(id: Int) - case 링크목록_안읽음 - case 링크목록_즐겨찾기 - case 컨텐츠_상세보기_delegate_위임 - } - } - /// initiallizer - public init() {} - /// - Reducer Core - private func core(into state: inout State, action: Action) -> Effect { - switch action { - /// - View - case .view(let viewAction): - return handleViewAction(viewAction, state: &state) - /// - Inner - case .inner(let innerAction): - return handleInnerAction(innerAction, state: &state) - /// - Async - case .async(let asyncAction): - return handleAsyncAction(asyncAction, state: &state) - /// - Scope - case .scope(let scopeAction): - return handleScopeAction(scopeAction, state: &state) - /// - Delegate - case .delegate(let delegateAction): - return handleDelegateAction(delegateAction, state: &state) - } - } - /// - Reducer body - public var body: some ReducerOf { - BindingReducer(action: \.view) - Reduce(self.core) - } -} -//MARK: - FeatureAction Effect -private extension RemindFeature { - /// - View Effect - func handleViewAction(_ action: Action.View, state: inout State) -> Effect { - switch action { - case .binding: - return .none - case .알림_버튼_눌렀을때: - return .send(.delegate(.alertButtonTapped)) - case .검색_버튼_눌렀을때: - return .send(.delegate(.searchButtonTapped)) - case .즐겨찾기_목록_버튼_눌렀을때: - return .send(.delegate(.링크목록_즐겨찾기)) - case .안읽음_목록_버튼_눌렀을때: - return .send(.delegate(.링크목록_안읽음)) - case .컨텐츠_항목_케밥_버튼_눌렀을때(let content): - return .send(.delegate(.링크상세(content: content))) - case .컨텐츠_항목_눌렀을때(let content): - guard let url = URL(string: content.data) else { return .none } - return .run { _ in await openURL(url) } - case .뷰가_나타났을때: - return allContentFetch(animation: .pokitDissolve) - case let .즐겨찾기_항목_이미지_조회(contentId): - return .send(.async(.즐겨찾기_이미지_조회_수행(contentId: contentId))) - case let .읽지않음_항목_이미지_조회(contentId): - return .send(.async(.읽지않음_이미지_조회_수행(contentId: contentId))) - case let .리마인드_항목_이미지오류_나타났을때(contentId): - return .send(.async(.리마인드_이미지_조회_수행(contentId: contentId))) - } - } - /// - Inner Effect - func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { - switch action { - case .오늘의_리마인드_조회_API_반영(contents: let contents): - state.domain.recommendedList = contents - return .none - case .읽지않음_컨텐츠_조회_API_반영(contentList: let contentList): - state.domain.unreadList = contentList - return .none - case .즐겨찾기_링크모음_조회_API_반영(contentList: let contentList): - state.domain.favoriteList = contentList - return .none - case let .즐겨찾기_이미지_조회_수행_반영(imageURL, index): - var content = state.domain.favoriteList.data?.remove(at: index) - content?.thumbNail = imageURL - guard let content else { return .none } - state.domain.favoriteList.data?.insert(content, at: index) - return .send(.async(.썸네일_수정_API(imageURL: imageURL, contentId: content.id))) - case let .읽지않음_이미지_조회_수행_반영(imageURL, index): - var content = state.domain.unreadList.data?.remove(at: index) - content?.thumbNail = imageURL - guard let content else { return .none } - state.domain.unreadList.data?.insert(content, at: index) - return .send(.async(.썸네일_수정_API(imageURL: imageURL, contentId: content.id))) - case let .리마인드_이미지_조회_수행_반영(imageURL, index): - var content = state.domain.recommendedList?.remove(at: index) - content?.thumbNail = imageURL - guard let content else { return .none } - state.domain.recommendedList?.insert(content, at: index) - return .send(.async(.썸네일_수정_API(imageURL: imageURL, contentId: content.id))) - } - } - /// - Async Effect - func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { - switch action { - case .오늘의_리마인드_조회_API: - return .run { send in - let contents = try await remindClient.오늘의_리마인드_조회().map { $0.toDomain() } - await send(.inner(.오늘의_리마인드_조회_API_반영(contents: contents)), animation: .pokitDissolve) - } - case .읽지않음_컨텐츠_조회_API: - return .run { [pageable = state.domain.unreadListPageable] send in - let contentList = try await remindClient.읽지않음_컨텐츠_조회( - BasePageableRequest( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ) - ).toDomain() - await send(.inner(.읽지않음_컨텐츠_조회_API_반영(contentList: contentList)), animation: .pokitDissolve) - } - case .즐겨찾기_링크모음_조회_API: - return .run { [pageable = state.domain.favoriteListPageable] send in - let contentList = try await remindClient.즐겨찾기_링크모음_조회( - BasePageableRequest( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ) - ).toDomain() - await send(.inner(.즐겨찾기_링크모음_조회_API_반영(contentList: contentList)), animation: .pokitDissolve) - } - case let .즐겨찾기_이미지_조회_수행(contentId): - return .run { [favoriteContents = state.favoriteContents] send in - guard - let index = favoriteContents?.index(id: contentId), - let content = favoriteContents?[index], - let url = URL(string: content.data) - else { return } - - let imageURL = try await swiftSoupClient.parseOGImageURL(url) - guard let imageURL else { return } - - await send(.inner(.즐겨찾기_이미지_조회_수행_반영( - imageURL: imageURL, - index: index - ))) - } - case let .읽지않음_이미지_조회_수행(contentId): - return .run { [unreadContents = state.unreadContents] send in - guard - let index = unreadContents?.index(id: contentId), - let content = unreadContents?[index], - let url = URL(string: content.data) - else { return } - let imageURL = try await swiftSoupClient.parseOGImageURL(url) - guard let imageURL else { return } - - await send(.inner(.읽지않음_이미지_조회_수행_반영( - imageURL: imageURL, - index: index - ))) - } - case let .리마인드_이미지_조회_수행(contentId): - return .run { [recommendedContents = state.recommendedContents] send in - guard - let index = recommendedContents?.index(id: contentId), - let content = recommendedContents?[index], - let url = URL(string: content.data) - else { return } - let imageURL = try await swiftSoupClient.parseOGImageURL(url) - guard let imageURL else { return } - - await send(.inner(.리마인드_이미지_조회_수행_반영( - imageURL: imageURL, - index: index - ))) - } - case let .썸네일_수정_API(imageURL, contentId): - return .run { send in - let request = ThumbnailRequest(thumbnail: imageURL) - - try await contentClient.썸네일_수정( - contentId: "\(contentId)", - model: request - ) - } - } - } - /// - Scope Effect - func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { - return .none - } - /// - Delegate Effect - func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { - switch action { - case .컨텐츠_상세보기_delegate_위임: - return allContentFetch() - default: return .none - } - } - - func allContentFetch(animation: Animation? = nil) -> Effect { - return .run { send in - await send(.async(.오늘의_리마인드_조회_API), animation: animation) - await send(.async(.읽지않음_컨텐츠_조회_API), animation: animation) - await send(.async(.즐겨찾기_링크모음_조회_API), animation: animation) - } - } -} diff --git a/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift b/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift deleted file mode 100644 index 885b091a..00000000 --- a/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift +++ /dev/null @@ -1,299 +0,0 @@ -// -// RemindView.swift -// Feature -// -// Created by 김도형 on 7/12/24. - -import SwiftUI - -import ComposableArchitecture -import Domain -import DSKit -import NukeUI -import Util - -@ViewAction(for: RemindFeature.self) -public struct RemindView: View { - /// - Properties - @Perception.Bindable - public var store: StoreOf - private let formatter = DateFormat.yearMonthDate.formatter - /// - Initializer - public init(store: StoreOf) { - self.store = store - } -} -//MARK: - View -public extension RemindView { - var body: some View { - WithPerceptionTracking { - contents - .background(.pokit(.bg(.base))) - .ignoresSafeArea(edges: .bottom) - .navigationBarBackButtonHidden(true) - .task { await send(.뷰가_나타났을때, animation: .pokitDissolve).finish() } - } - } -} -//MARK: - Configure View -extension RemindView { - private var contents: some View { - Group { - if let recommendedContents = store.recommendedContents, - let unreadContents = store.unreadContents, - let favoriteContents = store.favoriteContents { - if recommendedContents.isEmpty && - unreadContents.isEmpty && - favoriteContents.isEmpty { - VStack { - PokitCaution(type: .링크부족) - .padding(.top, 100) - - Spacer() - } - } else { - ScrollView { - VStack(spacing: 32) { - recommededContentList(recommendedContents) - - Group { - unreadContentList(unreadContents) - - favoriteContentList(favoriteContents) - } - .padding(.horizontal, 20) - - Spacer() - } - .padding(.bottom, 150) - } - } - } else { - PokitLoading() - } - } - } - - @ViewBuilder - private func recommededContentList( - _ recommendedContents: IdentifiedArrayOf - ) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text("오늘 이 링크는 어때요?") - .pokitFont(.title2) - .foregroundStyle(.pokit(.text(.primary))) - .padding(.horizontal, 20) - - if recommendedContents.isEmpty { - PokitCaution(type: .링크부족) - .padding(.top, 24) - .padding(.bottom, 32) - } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(recommendedContents, id: \.id) { content in - recommendedContentCell(content: content) - } - } - .padding(.horizontal, 20) - } - } - } - } - - @ViewBuilder - private func recommendedContentCell(content: BaseContentItem) -> some View { - Button(action: { send(.컨텐츠_항목_눌렀을때(content: content)) }) { - recommendedContentCellLabel(content: content) - } - } - - @ViewBuilder - private func recommendedContentCellLabel(content: BaseContentItem) -> some View { - ZStack(alignment: .bottom) { - LinearGradient( - stops: [ - Gradient.Stop( - color: .black.opacity(0), - location: 0.00 - ), - Gradient.Stop( - color: Color(red: 0.02, green: 0.02, blue: 0.02).opacity(0.49), - location: 1.00 - ), - ], - startPoint: .top, - endPoint: .bottom - ) - - VStack(alignment: .leading, spacing: 0) { - PokitBadge(state: .small(content.categoryName)) - - HStack(spacing: 4) { - Text(content.title) - .pokitFont(.b2(.b)) - .foregroundStyle(.pokit(.text(.inverseWh))) - .multilineTextAlignment(.leading) - .lineLimit(2) - - Spacer() - - kebabButton { - send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) - } - .foregroundStyle(.pokit(.icon(.inverseWh))) - .zIndex(1) - - } - .padding(.top, 4) - - Text("\(content.createdAt) • \(content.domain)") - .pokitFont(.detail2) - .foregroundStyle(.pokit(.text(.tertiary))) - .padding(.top, 8) - } - .padding(12) - } - .frame(width: 216, height: 194) - .background(ifLet: URL(string: content.thumbNail)) { url in - recommendedContentCellImage(url: url, contentId: content.id) - } else: { - imagePlaceholder - } - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .clipped() - } - - @MainActor - private func recommendedContentCellImage(url: URL, contentId: Int) -> some View { - LazyImage(url: url) { phase in - if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - } else if phase.error != nil { - imagePlaceholder - .task { await send(.리마인드_항목_이미지오류_나타났을때(contentId: contentId)).finish() } - } else { - imagePlaceholder - } - } - } - - private var imagePlaceholder: some View { - ZStack { - Color.pokit(.bg(.disable)) - - PokitSpinner() - .foregroundStyle(.pink) - .frame(width: 48, height: 48) - } - } - - @ViewBuilder - private func kebabButton(action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(.icon(.kebab)) - .resizable() - .frame(width: 24, height: 24) - } - } - - @ViewBuilder - private func listNavigationLink( - _ title: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack { - Text(title) - .pokitFont(.title2) - - Spacer() - - Image(.icon(.arrowRight)) - .resizable() - .frame(width: 24, height: 24) - } - .foregroundStyle(.pokit(.icon(.primary))) - } - } - - @ViewBuilder - private func unreadContentList( - _ unreadContents: IdentifiedArrayOf - ) -> some View { - Group { - if !unreadContents.isEmpty { - VStack(spacing: 0) { - VStack(spacing: 0) { - listNavigationLink("한번도 읽지 않았어요") { - send(.안읽음_목록_버튼_눌렀을때) - } - .padding(.bottom, 16) - } - - ForEach(unreadContents, id: \.id) { content in - let isFirst = content.id == unreadContents.first?.id - let isLast = content.id == unreadContents.last?.id - - PokitLinkCard( - link: content, - state: isFirst - ? .top - : isLast ? .bottom : .middle, - action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, - kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) }, - fetchMetaData: { send(.읽지않음_항목_이미지_조회(contentId: content.id)) } - ) - } - } - } - } - } - - @ViewBuilder - private func favoriteContentList( - _ favoriteContents: IdentifiedArrayOf - ) -> some View { - VStack(spacing: 0) { - listNavigationLink("즐겨찾기 링크만 모았어요") { - send(.즐겨찾기_목록_버튼_눌렀을때) - } - .padding(.bottom, 16) - - if favoriteContents.isEmpty { - PokitCaution(type: .즐겨찾기_링크없음) - .padding(.top, 16) - } else { - ForEach(favoriteContents, id: \.id) { content in - let isFirst = content.id == favoriteContents.first?.id - let isLast = content.id == favoriteContents.last?.id - - PokitLinkCard( - link: content, - state: isFirst - ? .top - : isLast ? .bottom : .middle, - action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, - kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) }, - fetchMetaData: { send(.즐겨찾기_항목_이미지_조회(contentId: content.id)) } - ) - } - } - } - } -} -//MARK: - Preview -#Preview { - NavigationStack { - RemindView( - store: Store( - initialState: .init(), - reducer: { RemindFeature() } - ) - ) - } -} - - diff --git a/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift b/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift deleted file mode 100644 index 9d09d8ee..00000000 --- a/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// App.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import SwiftUI -import ComposableArchitecture - -import FeatureRemind -import FeatureIntro - -@main -struct FeatureRemindDemoApp: App { - var body: some Scene { - WindowGroup { - // TODO: 루트 뷰 추가 - - DemoView(store: .init( - initialState: .init(), - reducer: { DemoFeature() } - )) { - NavigationStack { - RemindView( - store: .init( - initialState: .init(), - reducer: { RemindFeature() } - ) - ) - } - } - } - } -} - -#Preview { - RemindView( - store: .init( - initialState: .init(), - reducer: { RemindFeature() } - ) - ) -} diff --git a/Projects/Feature/FeatureRemindTests/Sources/FeatureRemindTests.swift b/Projects/Feature/FeatureRemindTests/Sources/FeatureRemindTests.swift deleted file mode 100644 index 3690595d..00000000 --- a/Projects/Feature/FeatureRemindTests/Sources/FeatureRemindTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import ComposableArchitecture -import XCTest - -@testable import FeatureRemind - -final class FeatureRemindTests: XCTestCase { - func test() { - - } -} diff --git a/Tuist/ProjectDescriptionHelpers/Feature.swift b/Tuist/ProjectDescriptionHelpers/Feature.swift index 3a220249..dae2e2fd 100644 --- a/Tuist/ProjectDescriptionHelpers/Feature.swift +++ b/Tuist/ProjectDescriptionHelpers/Feature.swift @@ -12,7 +12,6 @@ public enum Feature: String, CaseIterable { case contentDetail = "ContentDetail" case contentSetting = "ContentSetting" case categorySetting = "CategorySetting" - case remind = "Remind" case login = "Login" case pokit = "Pokit" case categoryDetail = "CategoryDetail" @@ -21,6 +20,7 @@ public enum Feature: String, CaseIterable { case categorySharing = "CategorySharing" case contentCard = "ContentCard" case intro = "Intro" + case recommend = "Recommend" public var target: Target { return .makeTarget( @@ -72,7 +72,6 @@ public enum Feature: String, CaseIterable { case .contentDetail: return [] case .contentSetting: return [] case .categorySetting: return [] - case .remind: return [] case .login: return [] case .pokit: return [ @@ -100,6 +99,7 @@ public enum Feature: String, CaseIterable { return [ .project(target: "FeatureLogin", path: .relativeToRoot("Projects/Feature")) ] + case .recommend: return [] } } } diff --git a/graph.png b/graph.png index 4f6618e4..792d0f33 100644 Binary files a/graph.png and b/graph.png differ