diff --git a/Projects/App/ShareExtension/Sources/ShareRootFeature.swift b/Projects/App/ShareExtension/Sources/ShareRootFeature.swift index 7e9b13aa..b63b2e44 100644 --- a/Projects/App/ShareExtension/Sources/ShareRootFeature.swift +++ b/Projects/App/ShareExtension/Sources/ShareRootFeature.swift @@ -200,7 +200,6 @@ struct ShareRootFeature { .ifLet(\.intro, action: \.intro) { IntroFeature() } .ifLet(\.contentSetting, action: \.contentSetting) { ContentSettingFeature() } .forEach(\.path, action: \.path) - ._printChanges() } } diff --git a/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient+LiveKey.swift index 13d2af80..f4649b8d 100644 --- a/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient+LiveKey.swift @@ -12,41 +12,14 @@ import SwiftSoup extension SwiftSoupClient: DependencyKey { public static let liveValue: Self = { + let provider = SwiftSoupProvider() + return Self( - parseOGTitleAndImage: { url, completion in - guard let html = try? String(contentsOf: url), - let document = try? SwiftSoup.parse(html) else { - await completion() - return (nil, nil) - } - - let title = try? document.select("meta[property=og:title]").first()?.attr("content") - let imageURL = try? document.select("meta[property=og:image]").first()?.attr("content") - - guard title != nil || imageURL != nil else { - var request = URLRequest(url: url) - request.setValue( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36", - forHTTPHeaderField: "User-Agent" - ) - - guard let data = try? await URLSession.shared.data(for: request).0, - let html = String(data: data, encoding: .utf8), - let document = try? SwiftSoup.parse(html) else { - return (nil, nil) - } - - let title = try? document.select("meta[property=og:title]").first()?.attr("content") - let imageURL = try? document.select("meta[property=og:image]").first()?.attr("content") - - await completion() - - return (title, imageURL) - } - - await completion() - - return (title, imageURL) + parseOGTitle: { url in + try await provider.parseOGTitle(url) + }, + parseOGImageURL: { url in + try await provider.parseOGImageURL(url) } ) }() diff --git a/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient.swift b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient.swift index 4311c9ba..6f2a18ab 100644 --- a/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient.swift +++ b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient.swift @@ -11,8 +11,11 @@ import DependenciesMacros @DependencyClient public struct SwiftSoupClient { - public var parseOGTitleAndImage: @Sendable ( - _ url: URL, - _ completion: @Sendable () async -> Void - ) async -> (String?, String?) = { _, _ in (nil , nil) } + public var parseOGTitle: @Sendable ( + _ url: URL + ) async throws -> String? = { _ in nil } + + public var parseOGImageURL: @Sendable ( + _ url: URL + ) async throws -> String? = { _ in nil } } diff --git a/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupProvider.swift b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupProvider.swift new file mode 100644 index 00000000..8a4e1613 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupProvider.swift @@ -0,0 +1,43 @@ +// +// SwiftSoupProvider.swift +// CoreKit +// +// Created by 김도형 on 11/17/24. +// + +import SwiftUI +import SwiftSoup + +final class SwiftSoupProvider { + func parseOGTitle(_ url: URL) async throws -> String? { + try await parseOGMeta(url: url, type: "og:title") + } + + func parseOGImageURL(_ url: URL) async throws -> String? { + try await parseOGMeta(url: url, type: "og:image") + } + + func parseOGMeta(url: URL, type: String) async throws -> String? { + let html = try String(contentsOf: url) + let document = try SwiftSoup.parse(html) + + if let metaData = try document.select("meta[property=\(type)]").first()?.attr("content") { + return metaData + } else { + var request = URLRequest(url: url) + request.setValue( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36", + forHTTPHeaderField: "User-Agent" + ) + + let (data, _) = try await URLSession.shared.data(for: request) + guard let html = String(data: data, encoding: .utf8) else { + return nil + } + let document = try SwiftSoup.parse(html) + let metaData = try document.select("meta[property=\(type)]").first()?.attr("content") + + return metaData + } + } +} diff --git a/Projects/DSKit/Sources/Components/PokitLinkCard.swift b/Projects/DSKit/Sources/Components/PokitLinkCard.swift index a282e330..a3d5a5be 100644 --- a/Projects/DSKit/Sources/Components/PokitLinkCard.swift +++ b/Projects/DSKit/Sources/Components/PokitLinkCard.swift @@ -14,15 +14,18 @@ public struct PokitLinkCard: View { private let link: Item private let action: () -> Void private let kebabAction: (() -> Void)? + private let fetchMetaData: (() -> Void)? public init( link: Item, action: @escaping () -> Void, - kebabAction: (() -> Void)? = nil + kebabAction: (() -> Void)? = nil, + fetchMetaData: (() -> Void)? = nil ) { self.link = link self.action = action self.kebabAction = kebabAction + self.fetchMetaData = fetchMetaData } public var body: some View { @@ -110,18 +113,15 @@ public struct PokitLinkCard: View { @MainActor private func thumbleNail(url: URL) -> some View { - var request = URLRequest(url: url) - request.setValue( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36", - forHTTPHeaderField: "User-Agent" - ) - - return LazyImage(request: .init(urlRequest: request)) { phase in + LazyImage(url: url) { phase in Group { if let image = phase.image { image .resizable() .aspectRatio(contentMode: .fill) + } else if phase.error != nil { + placeholder + .onAppear { fetchMetaData?() } } else { placeholder } diff --git a/Projects/DSKit/Sources/Components/PokitLinkPreview.swift b/Projects/DSKit/Sources/Components/PokitLinkPreview.swift index 406ccb98..1fc30916 100644 --- a/Projects/DSKit/Sources/Components/PokitLinkPreview.swift +++ b/Projects/DSKit/Sources/Components/PokitLinkPreview.swift @@ -41,6 +41,7 @@ public struct PokitLinkPreview: View { if let image = phase.image { image .resizable() + .aspectRatio(contentMode: .fill) } else { PokitSpinner() .foregroundStyle(.pokit(.icon(.brand))) @@ -56,6 +57,7 @@ public struct PokitLinkPreview: View { Spacer() } .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .clipped() .background { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(.pokit(.bg(.base))) diff --git a/Projects/Domain/Sources/Base/BaseContentItem.swift b/Projects/Domain/Sources/Base/BaseContentItem.swift index f9ed88f1..59c93b12 100644 --- a/Projects/Domain/Sources/Base/BaseContentItem.swift +++ b/Projects/Domain/Sources/Base/BaseContentItem.swift @@ -14,7 +14,7 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta public let categoryName: String public let categoryId: Int public let title: String - public let thumbNail: String + public var thumbNail: String public let data: String public let domain: String public let createdAt: String diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift index 121ab95c..7b8313e4 100644 --- a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift +++ b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift @@ -7,6 +7,7 @@ import Foundation import ComposableArchitecture +import FeatureContentCard import Domain import CoreKit import DSKit @@ -51,16 +52,7 @@ public struct CategoryDetailFeature { } return identifiedArray } - var contents: IdentifiedArrayOf? { - guard let contentList = domain.contentList.data else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - contentList.forEach { content in - identifiedArray.append(content) - } - return identifiedArray - } + var contents: IdentifiedArrayOf = [] var kebobSelectedType: PokitDeleteBottomSheet.SheetType? var selectedContentItem: BaseContentItem? var shareSheetItem: BaseContentItem? = nil @@ -73,6 +65,7 @@ public struct CategoryDetailFeature { var hasNext: Bool { domain.contentList.hasNext } + var isLoading: Bool = true public init(category: BaseCategoryItem) { self.domain = .init(categpry: category) @@ -86,6 +79,7 @@ public struct CategoryDetailFeature { case async(AsyncAction) case scope(ScopeAction) case delegate(DelegateAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: BindableAction, Equatable { @@ -121,10 +115,11 @@ public struct CategoryDetailFeature { case 클립보드_감지 } - public enum ScopeAction: Equatable { + public enum ScopeAction { case categoryBottomSheet(PokitBottomSheet.Delegate) case categoryDeleteBottomSheet(PokitDeleteBottomSheet.Delegate) case filterBottomSheet(CategoryFilterSheet.Delegate) + case contents(IdentifiedActionOf) } public enum DelegateAction: Equatable { @@ -163,6 +158,9 @@ public struct CategoryDetailFeature { /// - Delegate case .delegate(let delegateAction): return handleDelegateAction(delegateAction, state: &state) + + case .contents(let contentsAction): + return .send(.scope(.contents(contentsAction))) } } @@ -170,6 +168,9 @@ public struct CategoryDetailFeature { public var body: some ReducerOf { BindingReducer(action: \.view) Reduce(self.core) + .forEach(\.contents, action: \.contents) { + ContentCardFeature() + } } } //MARK: - FeatureAction Effect @@ -191,7 +192,7 @@ private extension CategoryDetailFeature { case .카테고리_선택했을때(let item): state.domain.category = item return .run { send in - await send(.inner(.pagenation_초기화)) + await send(.inner(.pagenation_초기화), animation: .pokitDissolve) await send(.async(.카테고리_내_컨텐츠_목록_조회_API)) await send(.inner(.카테고리_선택_시트_활성화(false))) } @@ -248,10 +249,17 @@ private extension CategoryDetailFeature { case .카테고리_내_컨텐츠_목록_조회_API_반영(let contentList): state.domain.contentList = contentList + + var identifiedArray = IdentifiedArrayOf() + contentList.data?.forEach { identifiedArray.append(.init(content: $0)) } + state.contents = identifiedArray + + state.isLoading = false return .none case let .컨텐츠_삭제_API_반영(id): state.domain.contentList.data?.removeAll { $0.id == id } + state.contents.removeAll { $0.content.id == id } state.domain.category.contentCount -= 1 state.selectedContentItem = nil state.isPokitDeleteSheetPresented = false @@ -264,11 +272,15 @@ private extension CategoryDetailFeature { state.domain.contentList = contentList state.domain.contentList.data = list + newList + newList.forEach { state.contents.append(.init(content: $0)) } + return .none case .pagenation_초기화: state.domain.pageable.page = 0 state.domain.contentList.data = nil + state.isLoading = true + state.contents.removeAll() return .none } } @@ -459,6 +471,15 @@ private extension CategoryDetailFeature { .send(.async(.카테고리_내_컨텐츠_목록_조회_API)) ) } + + case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_눌렀을때(content)))): + return .send(.delegate(.contentItemTapped(content))) + case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content)))): + state.kebobSelectedType = .링크삭제 + state.selectedContentItem = content + return .send(.inner(.카테고리_시트_활성화(true))) + case .contents: + return .none } } diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift index d424057a..5a066f7e 100644 --- a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift +++ b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import FeatureContentCard import Domain import DSKit import Util @@ -136,8 +137,8 @@ private extension CategoryDetailView { var contentScrollView: some View { Group { - if let contents = store.contents { - if contents.isEmpty { + if !store.isLoading { + if store.contents.isEmpty { VStack { PokitCaution( image: .empty, @@ -151,17 +152,17 @@ private extension CategoryDetailView { } else { ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { - ForEach(contents) { content in - let isFirst = content == contents.first - let isLast = content == contents.last + ForEach( + store.scope(state: \.contents, action: \.contents) + ) { store in + let isFirst = store.state.id == self.store.contents.first?.id + let isLast = store.state.id == self.store.contents.last?.id - PokitLinkCard( - link: content, - action: { send(.컨텐츠_항목_눌렀을때(content)) }, - kebabAction: { send(.카테고리_케밥_버튼_눌렀을때(.링크삭제, selectedItem: content)) } + ContentCardView( + store: store, + isFirst: isFirst, + isLast: isLast ) - .divider(isFirst: isFirst, isLast: isLast) - .pokitScrollTransition(.opacity) } if store.hasNext { diff --git a/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift b/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift index 67905525..9f97170e 100644 --- a/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift +++ b/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift @@ -7,6 +7,7 @@ import Foundation import ComposableArchitecture +import FeatureContentCard import Domain import CoreKit import Util @@ -26,16 +27,11 @@ public struct CategorySharingFeature { public struct State: Equatable { fileprivate var domain: CategorySharing var category: CategorySharing.Category { domain.sharedCategory.category } - var contents: IdentifiedArrayOf? { - var identifiedArray = IdentifiedArrayOf() - domain.sharedCategory.contentList.data.forEach { content in - identifiedArray.append(content) - } - return identifiedArray - } + var contents: IdentifiedArrayOf = [] var hasNext: Bool { domain.sharedCategory.contentList.hasNext } var error: BaseError? var isErrorSheetPresented: Bool = false + var isLoading: Bool = true public init(sharedCategory: CategorySharing.SharedCategory) { domain = .init( @@ -56,6 +52,7 @@ public struct CategorySharingFeature { case async(AsyncAction) case scope(ScopeAction) case delegate(DelegateAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: Equatable, BindableAction { @@ -63,9 +60,9 @@ public struct CategorySharingFeature { case dismiss case 저장_버튼_눌렀을때 - case 컨텐츠_항목_눌렀을때(CategorySharing.Content) case 경고_확인버튼_눌렀을때 case 페이지_로딩중일때 + case 뷰가_나타났을때 } public enum InnerAction: Equatable { @@ -78,7 +75,9 @@ public struct CategorySharingFeature { case 공유받은_카테고리_조회_API } - public enum ScopeAction: Equatable { case 없음 } + public enum ScopeAction { + case contents(IdentifiedActionOf) + } public enum DelegateAction: Equatable { case 컨텐츠_아이템_클릭(categoryId: Int, content: CategorySharing.Content) @@ -111,6 +110,9 @@ public struct CategorySharingFeature { /// - Delegate case .delegate(let delegateAction): return handleDelegateAction(delegateAction, state: &state) + + case .contents(let contentsAction): + return .send(.scope(.contents(contentsAction))) } } @@ -118,6 +120,9 @@ public struct CategorySharingFeature { public var body: some ReducerOf { BindingReducer(action: \.view) Reduce(self.core) + .forEach(\.contents, action: \.contents) { + ContentCardFeature() + } } } //MARK: - FeatureAction Effect @@ -135,14 +140,27 @@ private extension CategorySharingFeature { let sharedCategory = state.domain.sharedCategory.category return .send(.delegate(.공유받은_카테고리_추가(sharedCategory: sharedCategory))) - case let .컨텐츠_항목_눌렀을때(content): - return .send(.delegate(.컨텐츠_아이템_클릭(categoryId: state.category.categoryId , content: content))) - case .경고_확인버튼_눌렀을때: return .none case .페이지_로딩중일때: return .send(.async(.공유받은_카테고리_조회_API)) + case .뷰가_나타났을때: + state.domain.sharedCategory.contentList.data.forEach { content in + state.contents.append(.init(content: .init( + id: content.id, + categoryName: content.categoryName, + categoryId: state.category.categoryId, + title: content.title, + thumbNail: content.thumbNail, + data: content.data, + domain: content.domain, + createdAt: content.createdAt, + isRead: content.isRead + ))) + } + state.isLoading = false + return .none } } @@ -151,6 +169,21 @@ private extension CategorySharingFeature { switch action { case let .공유받은_카테고리_API_반영(sharedCategory): state.domain.sharedCategory = sharedCategory + + sharedCategory.contentList.data.forEach { content in + state.contents.append(.init(content: .init( + id: content.id, + categoryName: content.categoryName, + categoryId: state.category.categoryId, + title: content.title, + thumbNail: content.thumbNail, + data: content.data, + domain: content.domain, + createdAt: content.createdAt, + isRead: content.isRead + ))) + } + state.isLoading = false return .none case let .경고_띄움(baseError): @@ -186,7 +219,20 @@ private extension CategorySharingFeature { /// - Scope Effect func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { - return .none + switch action { + case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_눌렀을때(content)))): + let sharedContent = state.domain.sharedCategory.contentList.data.first { item in + item.id == content.id + } + guard let sharedContent else { return .none } + + return .send(.delegate(.컨텐츠_아이템_클릭( + categoryId: state.category.categoryId, + content: sharedContent + ))) + case .contents: + return .none + } } /// - Delegate Effect diff --git a/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingView.swift b/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingView.swift index 04d7cfa0..53e2844f 100644 --- a/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingView.swift +++ b/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import FeatureContentCard import Domain import DSKit @@ -34,6 +35,7 @@ public extension CategorySharingView { .padding(.top, 12) .pokitNavigationBar { navigationBar } .ignoresSafeArea(edges: .bottom) + .onAppear { send(.뷰가_나타났을때, animation: .pokitDissolve) } .sheet(isPresented: $store.isErrorSheetPresented) { PokitAlert( store.error?.title ?? "에러", @@ -86,8 +88,8 @@ private extension CategorySharingView { var contentScrollView: some View { Group { - if let contents = store.contents { - if contents.isEmpty { + if !store.isLoading { + if store.contents.isEmpty { VStack { PokitCaution( image: .empty, @@ -101,16 +103,17 @@ private extension CategorySharingView { } else { ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { - ForEach(contents) { content in - let isFirst = content == contents.first - let isLast = content == contents.last + ForEach( + store.scope(state: \.contents, action: \.contents) + ) { store in + let isFirst = store.state.id == self.store.contents.first?.id + let isLast = store.state.id == self.store.contents.last?.id - PokitLinkCard( - link: content, - action: { send(.컨텐츠_항목_눌렀을때(content)) } + ContentCardView( + store: store, + isFirst: isFirst, + isLast: isLast ) - .divider(isFirst: isFirst, isLast: isLast) - .pokitScrollTransition(.opacity) } if store.hasNext { diff --git a/Projects/Feature/FeatureContentCard/Resources/Resource.swift b/Projects/Feature/FeatureContentCard/Resources/Resource.swift new file mode 100644 index 00000000..43790c92 --- /dev/null +++ b/Projects/Feature/FeatureContentCard/Resources/Resource.swift @@ -0,0 +1,8 @@ +// +// Dummy.stencil.swift +// ProjectDescriptionHelpers +// +// Created by 김도형 on 6/16/24. +// + +import Foundation diff --git a/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift b/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift new file mode 100644 index 00000000..e2480df9 --- /dev/null +++ b/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift @@ -0,0 +1,141 @@ +// +// LinkCardFeature.swift +// Feature +// +// Created by 김도형 on 11/17/24. + +import Foundation + +import ComposableArchitecture +import Domain +import CoreKit +import Util + +@Reducer +public struct ContentCardFeature { + /// - Dependency + @Dependency(SwiftSoupClient.self) + private var swiftSoupClient + /// - State + @ObservableState + public struct State: Equatable, Identifiable { + public let id = UUID() + public var content: BaseContentItem + + public init(content: BaseContentItem) { + self.content = content + } + } + + /// - 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: Equatable { + case 컨텐츠_항목_눌렀을때 + case 컨텐츠_항목_케밥_버튼_눌렀을때 + case 메타데이터_조회 + } + + public enum InnerAction: Equatable { + case 메타데이터_조회_수행_반영(String) + } + + public enum AsyncAction: Equatable { + case 메타데이터_조회_수행 + } + + public enum ScopeAction: Equatable { case doNothing } + + public enum DelegateAction: Equatable { + case 컨텐츠_항목_눌렀을때(content: BaseContentItem) + case 컨텐츠_항목_케밥_버튼_눌렀을때(content: BaseContentItem) + } + } + + /// - 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 { + Reduce(self.core) + } +} +//MARK: - FeatureAction Effect +private extension ContentCardFeature { + /// - View Effect + func handleViewAction(_ action: Action.View, state: inout State) -> Effect { + switch action { + case .컨텐츠_항목_눌렀을때: + return .send(.delegate(.컨텐츠_항목_눌렀을때(content: state.content))) + case .컨텐츠_항목_케밥_버튼_눌렀을때: + return .send(.delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content: state.content))) + case .메타데이터_조회: + return .send(.async(.메타데이터_조회_수행)) + } + } + + /// - Inner Effect + func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { + switch action { + case let .메타데이터_조회_수행_반영(imageURL): + state.content.thumbNail = imageURL + return .none + } + } + + /// - Async Effect + func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { + switch action { + case .메타데이터_조회_수행: + guard let url = URL(string: state.content.data) else { + return .none + } + return .run { send in + let imageURL = try await swiftSoupClient.parseOGImageURL(url) + guard let imageURL else { return } + await send(.inner(.메타데이터_조회_수행_반영(imageURL))) + } + } + } + + /// - 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 + } +} diff --git a/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardView.swift b/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardView.swift new file mode 100644 index 00000000..086a4be0 --- /dev/null +++ b/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardView.swift @@ -0,0 +1,69 @@ +// +// LinkCardView.swift +// Feature +// +// Created by 김도형 on 11/17/24. + +import SwiftUI + +import ComposableArchitecture +import Domain +import DSKit + +@ViewAction(for: ContentCardFeature.self) +public struct ContentCardView: View { + /// - Properties + public var store: StoreOf + private let isFirst: Bool + private let isLast: Bool + + /// - Initializer + public init( + store: StoreOf, + isFirst: Bool = false, + isLast: Bool = false + ) { + self.store = store + self.isFirst = isFirst + self.isLast = isLast + } +} +//MARK: - View +public extension ContentCardView { + var body: some View { + WithPerceptionTracking { + PokitLinkCard( + link: store.content, + action: { send(.컨텐츠_항목_눌렀을때) }, + kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때) }, + fetchMetaData: { send(.메타데이터_조회) } + ) + .divider(isFirst: isFirst, isLast: isLast) + } + } +} +//MARK: - Configure View +private extension ContentCardView { + +} +//MARK: - Preview +#Preview { + ContentCardView( + store: Store( + initialState: .init(content: .init( + id: 1, + categoryName: "미분류", + categoryId: 992 , + title: "youtube", + thumbNail: "https://i.ytimg.com/vi/NnOC4_kH0ok/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDN6u6mTjbaVmRZ4biJS_aDq4uvAQ", + data: "https://www.youtube.com/watch?v=wtSwdGJzQCQ", + domain: "신서유기", + createdAt: "2024.08.08", + isRead: false + )), + reducer: { ContentCardFeature() } + ) + ) +} + + diff --git a/Projects/Feature/FeatureContentCardDemo/Resources/LaunchScreen.storyboard b/Projects/Feature/FeatureContentCardDemo/Resources/LaunchScreen.storyboard new file mode 100644 index 00000000..f1721f80 --- /dev/null +++ b/Projects/Feature/FeatureContentCardDemo/Resources/LaunchScreen.storyboard @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Feature/FeatureContentCardDemo/Sources/FeatureContentCardDemoApp.swift b/Projects/Feature/FeatureContentCardDemo/Sources/FeatureContentCardDemoApp.swift new file mode 100644 index 00000000..17fadce2 --- /dev/null +++ b/Projects/Feature/FeatureContentCardDemo/Sources/FeatureContentCardDemoApp.swift @@ -0,0 +1,17 @@ +// +// App.stencil.swift +// ProjectDescriptionHelpers +// +// Created by 김도형 on 6/16/24. +// + +import SwiftUI + +@main +struct FeatureContentCardDemoApp: App { + var body: some Scene { + WindowGroup { + // TODO: 루트 뷰 추가 + } + } +} diff --git a/Projects/Feature/FeatureContentCardTests/Resources/info.plist b/Projects/Feature/FeatureContentCardTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureContentCardTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift new file mode 100644 index 00000000..6e30407e --- /dev/null +++ b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureContentCard + +final class FeatureContentCardTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift index 2d2bb436..e6f39e3e 100644 --- a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift +++ b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift @@ -138,10 +138,14 @@ private extension ContentDetailFeature { func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { case .뷰가_나타났을때: - guard let id = state.domain.contentId else { + if let content = state.content { + state.domain.content = content + return .send(.inner(.URL_유효성_확인)) + } else if let id = state.domain.contentId { + return .send(.async(.컨텐츠_상세_조회_API(id: id))) + } else { return .none } - return .send(.async(.컨텐츠_상세_조회_API(id: id))) case .공유_버튼_눌렀을때: state.showShareSheet = true return .none @@ -181,10 +185,9 @@ private extension ContentDetailFeature { case .메타데이터_조회_수행(url: let url): return .run { send in /// - 링크에 대한 메타데이터의 제목 및 썸네일 항목 파싱 - let (title, imageURL) = await swiftSoup.parseOGTitleAndImage(url) { - await send(.inner(.linkPreview), animation: .pokitDissolve) - } - await send( + async let title = swiftSoup.parseOGTitle(url) + async let imageURL = swiftSoup.parseOGImageURL(url) + try await send( .inner(.메타데이터_조회_반영(title: title, imageURL: imageURL)), animation: .pokitDissolve ) @@ -192,7 +195,7 @@ private extension ContentDetailFeature { case let .메타데이터_조회_반영(title: title, imageURL: imageURL): state.linkTitle = title state.linkImageURL = imageURL - return .none + return .send(.inner(.linkPreview), animation: .pokitDissolve) case .URL_유효성_확인: guard let urlString = state.domain.content?.data, let url = URL(string: urlString) else { diff --git a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift index b5797db0..dfbf0c2d 100644 --- a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift +++ b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift @@ -7,6 +7,7 @@ import Foundation import ComposableArchitecture +import FeatureContentCard import Domain import CoreKit import DSKit @@ -32,14 +33,7 @@ public struct ContentListFeature { let contentType: ContentType fileprivate var domain = ContentList() - var contents: IdentifiedArrayOf? { - guard let contentList = domain.contentList.data else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - contentList.forEach { identifiedArray.append($0) } - return identifiedArray - } + var contents: IdentifiedArrayOf = [] var contentCount: Int { get { domain.contentCount } } @@ -52,6 +46,7 @@ public struct ContentListFeature { var hasNext: Bool { domain.contentList.hasNext } + var isLoading: Bool = true } /// - Action @@ -61,6 +56,7 @@ public struct ContentListFeature { case async(AsyncAction) case scope(ScopeAction) case delegate(DelegateAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: Equatable, BindableAction { @@ -102,11 +98,12 @@ public struct ContentListFeature { case 클립보드_감지 } - public enum ScopeAction: Equatable { + public enum ScopeAction { case bottomSheet( delegate: PokitBottomSheet.Delegate, content: BaseContentItem ) + case contents(IdentifiedActionOf) } public enum DelegateAction: Equatable { @@ -142,13 +139,18 @@ public struct ContentListFeature { /// - Delegate case .delegate(let delegateAction): return handleDelegateAction(delegateAction, state: &state) + + case let .contents(contentAction): + return .send(.scope(.contents(contentAction))) } } /// - Reducer body public var body: some ReducerOf { Reduce(self.core) - ._printChanges() + .forEach(\.contents, action: \.contents) { + ContentCardFeature() + } } } //MARK: - FeatureAction Effect @@ -208,17 +210,28 @@ private extension ContentListFeature { state.domain.contentList = contentList state.domain.contentList.data = list + newList + + newList.forEach { state.contents.append(.init(content: $0)) } return .none case .컨텐츠_삭제_API_반영(id: let id): state.alertItem = nil state.domain.contentList.data?.removeAll { $0.id == id } + state.contents.removeAll { $0.content.id == id } return .none case .컨텐츠_목록_조회_API_반영(let contentList): state.domain.contentList = contentList + + var identifiedArray = IdentifiedArrayOf() + contentList.data?.forEach { identifiedArray.append(.init(content: $0)) } + state.contents = identifiedArray + + state.isLoading = false return .none case .페이징_초기화: state.domain.pageable.page = 0 state.domain.contentList.data = nil + state.isLoading = true + state.contents.removeAll() return .send(.async(.컨텐츠_목록_조회_API), animation: .pokitDissolve) case let .컨텐츠_개수_업데이트(count): state.domain.contentCount = count @@ -303,6 +316,14 @@ private extension ContentListFeature { state.shareSheetItem = content return .none } + + case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_눌렀을때(content)))): + return .send(.delegate(.링크상세(content: content))) + case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content)))): + state.bottomSheetItem = content + return .none + case .contents: + return .none } } @@ -353,7 +374,7 @@ private extension ContentListFeature { contentItems?.data = items + newItems } guard let contentItems else { return } - await send(.inner(.컨텐츠_목록_조회_API_반영(contentItems))) + await send(.inner(.컨텐츠_목록_조회_API_반영(contentItems)), animation: .pokitDissolve) } } diff --git a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift index 53b6de19..e38a4713 100644 --- a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift +++ b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import FeatureContentCard import DSKit @ViewAction(for: ContentListFeature.self) @@ -86,8 +87,8 @@ private extension ContentListView { var list: some View { Group { - if let contents = store.contents { - if contents.isEmpty { + if !store.isLoading { + if store.contents.isEmpty { PokitCaution( image: .empty, titleKey: "즐겨찾기 링크가 없어요!", @@ -99,16 +100,17 @@ private extension ContentListView { } else { ScrollView { LazyVStack(spacing: 0) { - ForEach(contents) { content in - let isFirst = content == contents.first - let isLast = content == contents.last + ForEach( + store.scope(state: \.contents, action: \.contents) + ) { store in + let isFirst = store.state.id == self.store.contents.first?.id + let isLast = store.state.id == self.store.contents.last?.id - PokitLinkCard( - link: content, - action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, - kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) } + ContentCardView( + store: store, + isFirst: isFirst, + isLast: isLast ) - .divider(isFirst: isFirst, isLast: isLast) } if store.hasNext { diff --git a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift index 41fe1881..089b9fa3 100644 --- a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift +++ b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift @@ -231,10 +231,9 @@ private extension ContentSettingFeature { return .none case .메타데이터_조회_수행(url: let url): return .run { send in - let (title, imageURL) = await swiftSoup.parseOGTitleAndImage(url) { - await send(.inner(.linkPreview), animation: .pokitDissolve) - } - await send( + async let title = swiftSoup.parseOGTitle(url) + async let imageURL = swiftSoup.parseOGImageURL(url) + try await send( .inner(.메타데이텨_조회_반영(title: title, imageURL: imageURL)), animation: .pokitDissolve ) @@ -246,7 +245,7 @@ private extension ContentSettingFeature { state.domain.title = title } state.domain.thumbNail = imageURL - return .none + return .send(.inner(.linkPreview), animation: .pokitDissolve) case .URL_유효성_확인: guard let url = URL(string: state.domain.data), !state.domain.data.isEmpty else { diff --git a/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift b/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift index 30214a32..3e0f7d99 100644 --- a/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift +++ b/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift @@ -5,6 +5,7 @@ // Created by 김민호 on 7/16/24. import ComposableArchitecture +import FeatureContentCard import Domain import CoreKit import DSKit @@ -36,16 +37,7 @@ public struct PokitRootFeature { } return identifiedArray } - var unclassifiedContents: IdentifiedArrayOf? { - guard let unclassifiedContentList = domain.unclassifiedContentList.data else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - unclassifiedContentList.forEach { content in - identifiedArray.append(content) - } - return identifiedArray - } + var contents: IdentifiedArrayOf = [] var selectedKebobItem: BaseCategoryItem? var selectedUnclassifiedItem: BaseContentItem? @@ -56,6 +48,7 @@ public struct PokitRootFeature { var hasNext: Bool { domain.categoryList.hasNext } var unclassifiedHasNext: Bool { domain.unclassifiedContentList.hasNext } + var isLoading: Bool = true public init() { } } @@ -67,6 +60,7 @@ public struct PokitRootFeature { case async(AsyncAction) case scope(ScopeAction) case delegate(DelegateAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: BindableAction, Equatable { @@ -112,9 +106,10 @@ public struct PokitRootFeature { case 미분류_카테고리_컨텐츠_삭제_API(contentId: Int) } - public enum ScopeAction: Equatable { + public enum ScopeAction { case bottomSheet(PokitBottomSheet.Delegate) case deleteBottomSheet(PokitDeleteBottomSheet.Delegate) + case contents(IdentifiedActionOf) } public enum DelegateAction: Equatable { @@ -156,6 +151,9 @@ public struct PokitRootFeature { /// - Delegate case .delegate(let delegateAction): return handleDelegateAction(delegateAction, state: &state) + + case .contents(let contentsAciton): + return .send(.scope(.contents(contentsAciton))) } } @@ -163,6 +161,10 @@ public struct PokitRootFeature { public var body: some ReducerOf { BindingReducer(action: \.view) Reduce(self.core) + .forEach(\.contents, action: \.contents) { + ContentCardFeature() + } + } } //MARK: - FeatureAction Effect @@ -279,6 +281,12 @@ private extension PokitRootFeature { case .미분류_카테고리_조회_API_반영(contentList: let contentList): state.domain.unclassifiedContentList = contentList + + var contents = IdentifiedArrayOf() + contentList.data?.forEach { contents.append(.init(content: $0)) } + state.contents = contents + + state.isLoading = false return .none case let .카테고리_조회_API_반영(categoryList): @@ -301,6 +309,9 @@ private extension PokitRootFeature { state.domain.unclassifiedContentList = contentList state.domain.unclassifiedContentList.data = list + newList state.domain.pageable.size = 10 + newList.forEach { content in + state.contents.append(.init(content: content)) + } return .none case let .미분류_카테고리_컨텐츠_삭제_API_반영(contentId: contentId): @@ -308,6 +319,7 @@ private extension PokitRootFeature { return .none } state.domain.unclassifiedContentList.data?.remove(at: index) + state.contents.removeAll { $0.content.id == contentId } state.isPokitDeleteSheetPresented = false return .none @@ -315,6 +327,8 @@ private extension PokitRootFeature { state.domain.pageable.page = 0 state.domain.categoryList.data = nil state.domain.unclassifiedContentList.data = nil + state.isLoading = true + state.contents.removeAll() switch state.folderType { case .folder(.포킷): @@ -525,6 +539,14 @@ private extension PokitRootFeature { default: return .none } + case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_눌렀을때(content)))): + return .send(.delegate(.contentDetailTapped(content))) + case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content)))): + state.selectedUnclassifiedItem = content + return .send(.inner(.카테고리_시트_활성화(true))) + case .contents: + return .none + default: return .none } } diff --git a/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift b/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift index 05b8a1dc..912b10fb 100644 --- a/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift +++ b/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import FeatureContentCard import Domain import DSKit @@ -166,8 +167,8 @@ private extension PokitRootView { var unclassifiedView: some View { Group { - if let unclassifiedContents = store.unclassifiedContents { - if unclassifiedContents.isEmpty { + if !store.isLoading { + if store.contents.isEmpty { VStack { PokitCaution( image: .empty, @@ -179,7 +180,7 @@ private extension PokitRootView { Spacer() } } else { - unclassifiedList(unclassifiedContents) + unclassifiedList } } else { PokitLoading() @@ -187,20 +188,20 @@ private extension PokitRootView { } } - @ViewBuilder - func unclassifiedList(_ unclassifiedContents: IdentifiedArrayOf) -> some View { + var unclassifiedList: some View { ScrollView { LazyVStack(spacing: 0) { - ForEach(unclassifiedContents) { content in - let isFirst = content == unclassifiedContents.first - let isLast = content == unclassifiedContents.last - - PokitLinkCard( - link: content, - action: { send(.컨텐츠_항목_눌렀을때(content)) }, - kebabAction: { send(.미분류_케밥_버튼_눌렀을때(content)) } + ForEach( + store.scope(state: \.contents, action: \.contents) + ) { store in + let isFirst = store.state.id == self.store.contents.first?.id + let isLast = store.state.id == self.store.contents.last?.id + + ContentCardView( + store: store, + isFirst: isFirst, + isLast: isLast ) - .divider(isFirst: isFirst, isLast: isLast) } if store.unclassifiedHasNext { diff --git a/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift b/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift index 254e262a..24766a92 100644 --- a/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift +++ b/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift @@ -21,7 +21,8 @@ public struct RemindFeature { private var remindClient @Dependency(ContentClient.self) private var contentClient - + @Dependency(SwiftSoupClient.self) + private var swiftSoupClient /// - State @ObservableState public struct State: Equatable { @@ -84,19 +85,29 @@ public struct RemindFeature { case 링크_공유_완료 case 뷰가_나타났을때 + case 즐겨찾기_항목_이미지_조회(contentId: Int) + case 읽지않음_항목_이미지_조회(contentId: Int) + case 리마인드_항목_이미지오류_나타났을때(contentId: Int) } public enum InnerAction: Equatable { case 바텀시트_해제 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) case 컨텐츠_삭제_API_반영(id: Int) + } public enum AsyncAction: Equatable { case 오늘의_리마인드_조회_API case 읽지않음_컨텐츠_조회_API case 즐겨찾기_링크모음_조회_API case 컨텐츠_삭제_API(id: Int) + case 즐겨찾기_이미지_조회_수행(contentId: Int) + case 읽지않음_이미지_조회_수행(contentId: Int) + case 리마인드_이미지_조회_수행(contentId: Int) } public enum ScopeAction: Equatable { case bottomSheet( @@ -175,6 +186,12 @@ private extension RemindFeature { case .링크_공유_완료: state.shareSheetItem = nil return .none + 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 @@ -198,6 +215,24 @@ private extension RemindFeature { state.domain.unreadList.data?.removeAll { $0.id == contentId } state.domain.favoriteList.data?.removeAll { $0.id == contentId } 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 .none + 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 .none + 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 .none } } /// - Async Effect @@ -235,6 +270,52 @@ private extension RemindFeature { let _ = try await contentClient.컨텐츠_삭제("\(id)") await send(.inner(.컨텐츠_삭제_API_반영(id: id)), animation: .pokitSpring) } + 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 + ))) + } } } /// - Scope Effect diff --git a/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift b/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift index d44e9238..f4630aa5 100644 --- a/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift +++ b/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift @@ -143,7 +143,7 @@ extension RemindView { private func recommendedContentCellLabel(content: BaseContentItem) -> some View { ZStack(alignment: .bottom) { if let url = URL(string: content.thumbNail) { - recommendedContentCellImage(url: url) + recommendedContentCellImage(url: url, contentId: content.id) } else { imagePlaceholder } @@ -193,20 +193,19 @@ extension RemindView { } .frame(width: 216, height: 194) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .clipped() } @MainActor - private func recommendedContentCellImage(url: URL) -> some View { - var request = URLRequest(url: url) - request.setValue( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36", - forHTTPHeaderField: "User-Agent" - ) - - return LazyImage(request: .init(urlRequest: request)) { phase in + 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 } @@ -273,7 +272,8 @@ extension RemindView { PokitLinkCard( link: content, action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, - kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) } + kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) }, + fetchMetaData: { send(.읽지않음_항목_이미지_조회(contentId: content.id)) } ) .divider(isFirst: isFirst, isLast: isLast) } @@ -307,7 +307,8 @@ extension RemindView { PokitLinkCard( link: content, action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, - kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) } + kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) }, + fetchMetaData: { send(.즐겨찾기_항목_이미지_조회(contentId: content.id)) } ) .divider(isFirst: isFirst, isLast: isLast) } diff --git a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift index 2d03671c..c54bde37 100644 --- a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift @@ -7,6 +7,7 @@ import Foundation import ComposableArchitecture +import FeatureContentCard import Domain import CoreKit import DSKit @@ -45,14 +46,7 @@ public struct PokitSearchFeature { get { domain.condition.searchWord } set { domain.condition.searchWord = newValue } } - var resultList: IdentifiedArrayOf? { - guard let contentList = domain.contentList.data else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - contentList.forEach { identifiedArray.append($0) } - return identifiedArray - } + var contents: IdentifiedArrayOf = [] var favoriteFilter: Bool { get { domain.condition.favorites } set { domain.condition.favorites = newValue } @@ -77,6 +71,7 @@ public struct PokitSearchFeature { var hasNext: Bool { get { domain.contentList.hasNext } } + var isLoading: Bool = false /// sheet item var bottomSheetItem: BaseContentItem? = nil @@ -92,6 +87,7 @@ public struct PokitSearchFeature { case scope(ScopeAction) case delegate(DelegateAction) case fiterBottomSheet(PresentationAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: Equatable, BindableAction { @@ -150,12 +146,13 @@ public struct PokitSearchFeature { case 클립보드_감지 } - public enum ScopeAction: Equatable { + public enum ScopeAction { case filterBottomSheet(FilterBottomFeature.Action.DelegateAction) case bottomSheet( delegate: PokitBottomSheet.Delegate, content: BaseContentItem ) + case contents(IdentifiedActionOf) } public enum DelegateAction: Equatable { @@ -193,6 +190,9 @@ public struct PokitSearchFeature { case .fiterBottomSheet: return .none + + case .contents(let contentsAction): + return .send(.scope(.contents(contentsAction))) } } public enum CancelID { case response } @@ -200,6 +200,9 @@ public struct PokitSearchFeature { public var body: some ReducerOf { BindingReducer(action: \.view) Reduce(self.core) + .forEach(\.contents, action: \.contents) { + ContentCardFeature() + } .ifLet(\.$filterBottomSheet, action: \.fiterBottomSheet) { FilterBottomFeature() } @@ -409,6 +412,11 @@ private extension PokitSearchFeature { case .컨텐츠_검색_API_반영(let contentList): state.domain.contentList = contentList + + var contents = IdentifiedArrayOf() + contentList.data?.forEach { contents.append(.init(content: $0)) } + state.contents = contents + state.isLoading = false return .send(.inner(.검색창_활성화(true))) case .최근검색어_불러오기: @@ -435,6 +443,7 @@ private extension PokitSearchFeature { case let .컨텐츠_삭제_API_반영(id): state.alertItem = nil state.domain.contentList.data?.removeAll { $0.id == id } + state.contents.removeAll { $0.content.id == id } return .none case let .컨텐츠_검색_페이징_API_반영(contentList): @@ -443,11 +452,16 @@ private extension PokitSearchFeature { state.domain.contentList = contentList state.domain.contentList.data = list + newList + + newList.forEach { state.contents.append(.init(content: $0)) } + return .send(.inner(.검색창_활성화(true))) case .페이징_초기화: state.domain.pageable.page = 0 state.domain.contentList.data = nil + state.isLoading = true + state.contents.removeAll() return .send(.async(.컨텐츠_검색_API), animation: .pokitDissolve) } } @@ -515,6 +529,8 @@ private extension PokitSearchFeature { pageableRequest, conditionRequest ).toDomain() + + await send(.inner(.컨텐츠_검색_페이징_API_반영(contentList))) } case .클립보드_감지: @@ -559,6 +575,14 @@ private extension PokitSearchFeature { state.shareSheetItem = content return .none } + + case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_눌렀을때(content)))): + return .send(.delegate(.linkCardTapped(content: content))) + case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content)))): + state.bottomSheetItem = content + return .none + case .contents: + return .none } } diff --git a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift index d92902e0..e0938d6e 100644 --- a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift +++ b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import FeatureContentCard import DSKit @ViewAction(for: PokitSearchFeature.self) @@ -290,19 +291,20 @@ private extension PokitSearchView { .contentTransition(.numericText()) .padding(.horizontal, 20) - if let results = store.resultList { + if !store.isLoading { ScrollView { LazyVStack(spacing: 0) { - ForEach(results, id: \.id) { content in - let isFirst = content == results.first - let isLast = content == results.last + ForEach( + store.scope(state: \.contents, action: \.contents) + ) { store in + let isFirst = store.state.id == self.store.contents.first?.id + let isLast = store.state.id == self.store.contents.last?.id - PokitLinkCard( - link: content, - action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, - kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) } + ContentCardView( + store: store, + isFirst: isFirst, + isLast: isLast ) - .divider(isFirst: isFirst, isLast: isLast) } if store.hasNext { diff --git a/Tuist/ProjectDescriptionHelpers/Feature.swift b/Tuist/ProjectDescriptionHelpers/Feature.swift index 88a3412f..4f541ff5 100644 --- a/Tuist/ProjectDescriptionHelpers/Feature.swift +++ b/Tuist/ProjectDescriptionHelpers/Feature.swift @@ -19,6 +19,7 @@ public enum Feature: String, CaseIterable { case setting = "Setting" case contentList = "ContentList" case categorySharing = "CategorySharing" + case contentCard = "ContentCard" public var target: Target { return .makeTarget( @@ -26,7 +27,7 @@ public enum Feature: String, CaseIterable { product: TuistRelease.isRelease ? .staticFramework : .framework, bundleName: "Feature.\(self.rawValue)", infoPlist: .file(path: .relativeToRoot("Projects/App/Resources/Pokit-info.plist")), - dependencies: [ + dependencies: self.depenecies + [ .project(target: "DSKit", path: .relativeToRoot("Projects/DSKit")), .project(target: "Domain", path: .relativeToRoot("Projects/Domain")) ] @@ -58,4 +59,35 @@ public enum Feature: String, CaseIterable { ] ) } + + public var depenecies: [TargetDependency] { + switch self { + case .contentDetail: return [] + case .contentSetting: return [] + case .categorySetting: return [] + case .remind: return [] + case .login: return [] + case .pokit: + return [ + .project(target: "FeatureContentCard", path: .relativeToRoot("Projects/Feature")) + ] + case .categoryDetail: + return [ + .project(target: "FeatureContentCard", path: .relativeToRoot("Projects/Feature")) + ] + case .setting: + return [ + .project(target: "FeatureContentCard", path: .relativeToRoot("Projects/Feature")) + ] + case .contentList: + return [ + .project(target: "FeatureContentCard", path: .relativeToRoot("Projects/Feature")) + ] + case .categorySharing: + return [ + .project(target: "FeatureContentCard", path: .relativeToRoot("Projects/Feature")) + ] + case .contentCard: return [] + } + } } diff --git a/graph.png b/graph.png index bf36f328..d8bc8e12 100644 Binary files a/graph.png and b/graph.png differ