diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 51ce1d00..3a6b76a7 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -26,7 +26,7 @@ let shareExtensionTarget: Target = .target( resources: ["ShareExtension/Resources/**"], entitlements: .file(path: .relativeToRoot("Projects/App/ShareExtension/ShareExtension.entitlements")), dependencies: [ - .project(target: "FeatureLogin", path: .relativeToRoot("Projects/Feature")), + .project(target: "FeatureIntro", path: .relativeToRoot("Projects/Feature")), .project(target: "FeatureContentSetting", path: .relativeToRoot("Projects/Feature")), .project(target: "FeatureCategorySetting", path: .relativeToRoot("Projects/Feature")) ], diff --git a/Projects/App/Resources/LaunchScreen.storyboard b/Projects/App/Resources/LaunchScreen.storyboard index c8502224..3e576b0e 100644 --- a/Projects/App/Resources/LaunchScreen.storyboard +++ b/Projects/App/Resources/LaunchScreen.storyboard @@ -1,9 +1,9 @@ - + - + @@ -17,18 +17,20 @@ - + - + + - + + @@ -40,7 +42,7 @@ - + diff --git a/Projects/App/ShareExtension/Sources/ShareRootFeature.swift b/Projects/App/ShareExtension/Sources/ShareRootFeature.swift index 0634c43d..c34a830e 100644 --- a/Projects/App/ShareExtension/Sources/ShareRootFeature.swift +++ b/Projects/App/ShareExtension/Sources/ShareRootFeature.swift @@ -9,7 +9,7 @@ import UIKit import UniformTypeIdentifiers import ComposableArchitecture -import FeatureLogin +import FeatureIntro import FeatureContentSetting import FeatureCategorySetting import CoreKit diff --git a/Projects/App/ShareExtension/Sources/ShareRootView.swift b/Projects/App/ShareExtension/Sources/ShareRootView.swift index f538be53..d62c665e 100644 --- a/Projects/App/ShareExtension/Sources/ShareRootView.swift +++ b/Projects/App/ShareExtension/Sources/ShareRootView.swift @@ -8,7 +8,7 @@ import SwiftUI import ComposableArchitecture -import FeatureLogin +import FeatureIntro import FeatureContentSetting import FeatureCategorySetting import DSKit diff --git a/Projects/App/Sources/Root/RootFeature.swift b/Projects/App/Sources/Root/RootFeature.swift index a31a69de..f372d349 100644 --- a/Projects/App/Sources/Root/RootFeature.swift +++ b/Projects/App/Sources/Root/RootFeature.swift @@ -8,7 +8,7 @@ import Foundation import ComposableArchitecture -import FeatureLogin +import FeatureIntro import CoreKit @Reducer diff --git a/Projects/App/Sources/Root/RootView.swift b/Projects/App/Sources/Root/RootView.swift index 748e5057..1d9d7d15 100644 --- a/Projects/App/Sources/Root/RootView.swift +++ b/Projects/App/Sources/Root/RootView.swift @@ -8,7 +8,7 @@ import SwiftUI import ComposableArchitecture -import FeatureLogin +import FeatureIntro import DSKit public struct RootView: View { diff --git a/Projects/DSKit/Sources/Components/PokitHeader.swift b/Projects/DSKit/Sources/Components/PokitHeader.swift index 3523a0a6..804ff627 100644 --- a/Projects/DSKit/Sources/Components/PokitHeader.swift +++ b/Projects/DSKit/Sources/Components/PokitHeader.swift @@ -7,6 +7,8 @@ import SwiftUI +import Util + public struct PokitHeader: View { private let title: String? @@ -28,12 +30,10 @@ public struct PokitHeader: View { .padding(.horizontal, 20) .padding(.vertical, 12) .background(.pokit(.bg(.base))) - .overlay { - if let title { - Text(title) - .pokitFont(.title3) - .foregroundStyle(.pokit(.text(.primary))) - } + .overlay(ifLet: title) { title in + Text(title) + .pokitFont(.title3) + .foregroundStyle(.pokit(.text(.primary))) } } } diff --git a/Projects/DSKit/Sources/Components/PokitList.swift b/Projects/DSKit/Sources/Components/PokitList.swift index ac0b3a76..6f391b07 100644 --- a/Projects/DSKit/Sources/Components/PokitList.swift +++ b/Projects/DSKit/Sources/Components/PokitList.swift @@ -83,15 +83,13 @@ public struct PokitList: View { } .padding(.vertical, 12) .padding(.horizontal, 20) - .background { - if isSelected { - Color.pokit(.bg(.primary)) - .matchedGeometryEffect(id: "SELECT", in: heroEffect) - } else { - isDisabled - ? Color.pokit(.bg(.disable)) - : Color.pokit(.bg(.base)) - } + .background(if: isSelected) { + Color.pokit(.bg(.primary)) + .matchedGeometryEffect(id: "SELECT", in: heroEffect) + } else: { + isDisabled + ? Color.pokit(.bg(.disable)) + : Color.pokit(.bg(.base)) } } .animation(.pokitDissolve, value: isSelected) diff --git a/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift b/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift index 5255b2b1..8b579616 100644 --- a/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift +++ b/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift @@ -48,11 +48,13 @@ public struct ContentCardFeature { case 메타데이터_조회 } + @CasePathable public enum InnerAction: Equatable { case 메타데이터_조회_수행_반영(String) case 즐겨찾기_API_반영(Bool) } + @CasePathable public enum AsyncAction: Equatable { case 메타데이터_조회_수행 case 즐겨찾기_API @@ -113,7 +115,7 @@ private extension ContentCardFeature { case .컨텐츠_항목_케밥_버튼_눌렀을때: return .send(.delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content: state.content))) case .메타데이터_조회: - return .send(.async(.메타데이터_조회_수행)) + return shared(.async(.메타데이터_조회_수행), state: &state) case .즐겨찾기_버튼_눌렀을때: guard let isFavorite = state.content.isFavorite else { return .none @@ -121,8 +123,8 @@ private extension ContentCardFeature { UIImpactFeedbackGenerator(style: .light) .impactOccurred() return isFavorite - ? .send(.async(.즐겨찾기_취소_API)) - : .send(.async(.즐겨찾기_API)) + ? shared(.async(.즐겨찾기_취소_API), state: &state) + : shared(.async(.즐겨찾기_API), state: &state) } } @@ -131,7 +133,7 @@ private extension ContentCardFeature { switch action { case let .메타데이터_조회_수행_반영(imageURL): state.content.thumbNail = imageURL - return .send(.async(.썸네일_수정_API)) + return shared(.async(.썸네일_수정_API), state: &state) case .즐겨찾기_API_반영(let favorite): state.content.isFavorite = favorite return .none @@ -164,10 +166,7 @@ private extension ContentCardFeature { return .run { [content = state.content] _ in let request = ThumbnailRequest(thumbnail: content.thumbNail) - try await contentClient.썸네일_수정( - contentId: "\(content.id)", - model: request - ) + try await contentClient.썸네일_수정("\(content.id)", request) } } } @@ -181,4 +180,19 @@ private extension ContentCardFeature { func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { return .none } + + 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) + } + } } diff --git a/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift index 6e30407e..d5ea6705 100644 --- a/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift +++ b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift @@ -1,10 +1,87 @@ import ComposableArchitecture import XCTest +import Domain +import CoreKit @testable import FeatureContentCard -final class FeatureContentCardTests: XCTestCase { - func test() { +final class SendTests: XCTestCase { + func test_primeTest() async { + let count = 10 + var sharedAverage: CFAbsoluteTime = 0.0 + for _ in 0.. String? = { _ in + "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + } + + $0[SwiftSoupClient.self].parseOGImageURL = parseOGImageURL + } + + let start = CFAbsoluteTimeGetCurrent() + await store.send(.view(.메타데이터_조회)) + await store.receive(\.inner.메타데이터_조회_수행_반영) { + $0.content.thumbNail = "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + } + let end = CFAbsoluteTimeGetCurrent() + average += end - start + } + + @MainActor + func test_shared_메서드_미적용(_ average: inout CFAbsoluteTime) async { + let store = TestStore(initialState: LegacyContentCardFeature.State( + content: ContentBaseResponse.mock(id: 0).toDomain() + )) { + LegacyContentCardFeature()._printChanges(.actionLabels) + } withDependencies: { + $0[ContentClient.self] = .testValue + let parseOGImageURL: @Sendable ( + _ url: URL + ) async throws -> String? = { _ in + "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + } + + $0[SwiftSoupClient.self].parseOGImageURL = parseOGImageURL + } + + let start = CFAbsoluteTimeGetCurrent() + await store.send(.view(.메타데이터_조회)) + await store.receive(\.async.메타데이터_조회_수행) + await store.receive(\.inner.메타데이터_조회_수행_반영) { + $0.content.thumbNail = "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + } + await store.receive(\.async.썸네일_수정_API) + let end = CFAbsoluteTimeGetCurrent() + average += end - start } } + + diff --git a/Projects/Feature/FeatureContentCardTests/Sources/LegacyContentCardFeature.swift b/Projects/Feature/FeatureContentCardTests/Sources/LegacyContentCardFeature.swift new file mode 100644 index 00000000..48f33836 --- /dev/null +++ b/Projects/Feature/FeatureContentCardTests/Sources/LegacyContentCardFeature.swift @@ -0,0 +1,184 @@ +// +// LegacyContentCardFeature.swift +// FeatureContentCardTests +// +// Created by 김도형 on 1/17/25. +// + +import SwiftUI + +import ComposableArchitecture +import Domain +import CoreKit +import DSKit +import Util + +@Reducer +public struct LegacyContentCardFeature { + /// - Dependency + @Dependency(SwiftSoupClient.self) + private var swiftSoupClient + @Dependency(\.openURL) + private var openURL + @Dependency(ContentClient.self) + private var contentClient + /// - 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 즐겨찾기_버튼_눌렀을때 + case 메타데이터_조회 + } + + @CasePathable + public enum InnerAction: Equatable { + case 메타데이터_조회_수행_반영(String) + case 즐겨찾기_API_반영(Bool) + } + + @CasePathable + public enum AsyncAction: Equatable { + case 메타데이터_조회_수행 + case 즐겨찾기_API + case 즐겨찾기_취소_API + case 썸네일_수정_API + } + + public enum ScopeAction: Equatable { case doNothing } + + public enum DelegateAction: Equatable { + 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 LegacyContentCardFeature { + /// - View Effect + func handleViewAction(_ action: Action.View, state: inout State) -> Effect { + switch action { + case .컨텐츠_항목_눌렀을때: + guard let url = URL(string: state.content.data) else { + return .none + } + return .run { _ in await openURL(url) } + case .컨텐츠_항목_케밥_버튼_눌렀을때: + return .send(.delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content: state.content))) + case .메타데이터_조회: + return .send(.async(.메타데이터_조회_수행)) + case .즐겨찾기_버튼_눌렀을때: + guard let isFavorite = state.content.isFavorite else { + return .none + } + UIImpactFeedbackGenerator(style: .light) + .impactOccurred() + return isFavorite + ? .send(.async(.즐겨찾기_취소_API)) + : .send(.async(.즐겨찾기_API)) + } + } + + /// - Inner Effect + func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { + switch action { + case let .메타데이터_조회_수행_반영(imageURL): + state.content.thumbNail = imageURL + return .send(.async(.썸네일_수정_API)) + case .즐겨찾기_API_반영(let favorite): + state.content.isFavorite = favorite + 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))) + } + case .즐겨찾기_API: + return .run { [id = state.content.id] send in + let _ = try await contentClient.즐겨찾기("\(id)") + await send(.inner(.즐겨찾기_API_반영(true)), animation: .pokitDissolve) + } + case .즐겨찾기_취소_API: + return .run { [id = state.content.id] send in + try await contentClient.즐겨찾기_취소("\(id)") + await send(.inner(.즐겨찾기_API_반영(false)), animation: .pokitDissolve) + } + case .썸네일_수정_API: + return .run { [content = state.content] _ in + let request = ThumbnailRequest(thumbnail: content.thumbNail) + + try await contentClient.썸네일_수정("\(content.id)", 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 { + return .none + } +} diff --git a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift index b0247281..01f6b0fd 100644 --- a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift +++ b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import Domain import DSKit +import Util @ViewAction(for: ContentDetailFeature.self) public struct ContentDetailView: View { @@ -52,10 +53,8 @@ public extension ContentDetailView { .pokitPresentationCornerRadius() .presentationDragIndicator(.visible) .presentationDetents([.height(588), .large]) - .overlay(alignment: .bottom) { - if store.linkPopup != nil { - PokitLinkPopup(type: $store.linkPopup) - } + .overlay(if: store.linkPopup != nil, alignment: .bottom) { + PokitLinkPopup(type: $store.linkPopup) } .sheet(isPresented: $store.showAlert) { PokitAlert( diff --git a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift index 48ca2c76..5afb5cdf 100644 --- a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift +++ b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift @@ -120,7 +120,7 @@ public struct ContentListFeature { return handleDelegateAction(delegateAction, state: &state) case let .contents(contentAction): - return .send(.scope(.contents(contentAction))) + return shared(.scope(.contents(contentAction)), state: &state) } } @@ -144,17 +144,18 @@ private extension ContentListFeature { state.domain.pageable.sort = [ state.isListDescending ? "createdAt,desc" : "createdAt,asc" ] - return .send(.inner(.페이징_초기화), animation: .pokitDissolve) + return shared(.inner(.페이징_초기화), state: &state) + .animation(.pokitDissolve) case .dismiss: return .run { _ in await dismiss() } case .뷰가_나타났을때: return .merge( - .send(.async(.컨텐츠_개수_조회_API)), - .send(.async(.컨텐츠_목록_조회_API)), - .send(.async(.클립보드_감지)) + shared(.async(.컨텐츠_개수_조회_API), state: &state), + shared(.async(.컨텐츠_목록_조회_API), state: &state), + shared(.async(.클립보드_감지), state: &state) ) case .pagenation: - return .send(.async(.컨텐츠_목록_조회_페이징_API)) + return shared(.async(.컨텐츠_목록_조회_페이징_API), state: &state) } } @@ -184,7 +185,8 @@ private extension ContentListFeature { state.domain.contentList.data = nil state.isLoading = true state.contents.removeAll() - return .send(.async(.컨텐츠_목록_조회_API), animation: .pokitDissolve) + return shared(.async(.컨텐츠_목록_조회_API), state: &state) + .animation(.pokitDissolve) case let .컨텐츠_개수_업데이트(count): state.domain.contentCount = count return .none @@ -247,7 +249,7 @@ private extension ContentListFeature { /// - 링크에 대한 `공유` / `수정` / `삭제` delegate switch action { case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content)))): - return .send(.delegate(.링크상세(content: content))) + return shared(.delegate(.링크상세(content: content)), state: &state) case .contents: return .none } @@ -257,7 +259,7 @@ private extension ContentListFeature { func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { switch action { case .컨텐츠_목록_조회: - return .send(.async(.컨텐츠_목록_조회_API)) + return shared(.async(.컨텐츠_목록_조회_API), state: &state) default: return .none } @@ -303,6 +305,24 @@ private extension ContentListFeature { await send(.inner(.컨텐츠_목록_조회_API_반영(contentItems)), animation: .pokitDissolve) } } + + /// - 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) + case let .contents(contentAction): + return shared(.scope(.contents(contentAction)), state: &state) + } + } } public extension ContentListFeature { diff --git a/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift b/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift index 5df9ed37..06fc4f72 100644 --- a/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift +++ b/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift @@ -8,16 +8,23 @@ import SwiftUI import FeatureContentList +import FeatureIntro @main struct FeatureContentListDemoApp: App { var body: some Scene { WindowGroup { // TODO: 루트 뷰 추가 - ContentListView(store: .init( - initialState: .init(contentType: .unread), - reducer: { ContentListFeature()._printChanges() } - )) + + DemoView(store: .init( + initialState: .init(), + reducer: { DemoFeature() } + )) { + ContentListView(store: .init( + initialState: .init(contentType: .favorite), + reducer: { ContentListFeature() } + )) + } } } } diff --git a/Projects/Feature/FeatureIntro/Resources/Resource.swift b/Projects/Feature/FeatureIntro/Resources/Resource.swift new file mode 100644 index 00000000..43790c92 --- /dev/null +++ b/Projects/Feature/FeatureIntro/Resources/Resource.swift @@ -0,0 +1,8 @@ +// +// Dummy.stencil.swift +// ProjectDescriptionHelpers +// +// Created by 김도형 on 6/16/24. +// + +import Foundation diff --git a/Projects/Feature/FeatureIntro/Sources/Demo/DemoFeature.swift b/Projects/Feature/FeatureIntro/Sources/Demo/DemoFeature.swift new file mode 100644 index 00000000..56e10cc1 --- /dev/null +++ b/Projects/Feature/FeatureIntro/Sources/Demo/DemoFeature.swift @@ -0,0 +1,141 @@ +// +// DemoFeature.swift +// Feature +// +// Created by 김도형 on 12/24/24. + +import ComposableArchitecture +import Util + +@Reducer +public struct DemoFeature { + /// - Dependency + + /// - State + @ObservableState + public enum State { + case intro(IntroFeature.State = .init()) + case main + + public init() { self = .intro() } + } + + /// - Action + public enum Action: FeatureAction, ViewAction { + case view(View) + case inner(InnerAction) + case async(AsyncAction) + case scope(ScopeAction) + case delegate(DelegateAction) + case intro(IntroFeature.Action) + + @CasePathable + public enum View: Equatable { case doNothing } + + public enum InnerAction: Equatable { case doNothing } + + public enum AsyncAction: Equatable { case doNothing } + + public enum ScopeAction { + case intro(IntroFeature.Action) + } + + public enum DelegateAction: Equatable { case doNothing } + } + + /// - 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) + + case .intro(let introAction): + return shared(.scope(.intro(introAction)), state: &state) + } + } + + /// - Reducer body + public var body: some ReducerOf { + Reduce(self.core) + .ifCaseLet(\.intro, action: \.intro) { IntroFeature() } + } +} +//MARK: - FeatureAction Effect +private extension DemoFeature { + /// - View Effect + func handleViewAction(_ action: Action.View, state: inout State) -> Effect { + return .none + } + + /// - Inner Effect + func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { + return .none + } + + /// - Async Effect + func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { + return .none + } + + /// - Scope Effect + func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { + switch action { + case .intro(.delegate(.moveToTab)): + state = .main + return .none + case .intro: return .none + } + } + + /// - Delegate Effect + func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { + return .none + } + + func shared(_ action: Action, state: inout State) -> 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) + + case .intro(let introAction): + return shared(.scope(.intro(introAction)), state: &state) + } + } +} diff --git a/Projects/Feature/FeatureIntro/Sources/Demo/DemoView.swift b/Projects/Feature/FeatureIntro/Sources/Demo/DemoView.swift new file mode 100644 index 00000000..6f74edd7 --- /dev/null +++ b/Projects/Feature/FeatureIntro/Sources/Demo/DemoView.swift @@ -0,0 +1,61 @@ +// +// DemoView.swift +// Feature +// +// Created by 김도형 on 12/24/24. + +import ComposableArchitecture +import SwiftUI + +@ViewAction(for: DemoFeature.self) +public struct DemoView: View { + /// - Properties + public let store: StoreOf + + @ViewBuilder + private let mainView: T + + /// - Initializer + public init( + store: StoreOf, + @ViewBuilder mainView: () -> T + ) { + self.store = store + self.mainView = mainView() + } +} +//MARK: - View +public extension DemoView { + var body: some View { + WithPerceptionTracking { + Group { + switch store.state { + case .intro: + if let store = store.scope(state: \.intro, action: \.intro) { + IntroView(store: store) + } + case .main: + mainView + } + } + .background(.pokit(.bg(.base))) + .ignoresSafeArea(edges: .bottom) + .animation(.smooth, value: store) + } + } +} +//MARK: - Configure View +private extension DemoView { + +} +//MARK: - Preview +#Preview { + DemoView(store: Store( + initialState: .init(), + reducer: { DemoFeature() } + )) { + + } +} + + diff --git a/Projects/Feature/FeatureLogin/Sources/Intro/IntroFeature.swift b/Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift similarity index 99% rename from Projects/Feature/FeatureLogin/Sources/Intro/IntroFeature.swift rename to Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift index 584b66be..6841a3eb 100644 --- a/Projects/Feature/FeatureLogin/Sources/Intro/IntroFeature.swift +++ b/Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift @@ -5,6 +5,7 @@ // Created by 김민호 on 7/11/24. import ComposableArchitecture +import FeatureLogin import CoreKit @Reducer diff --git a/Projects/Feature/FeatureLogin/Sources/Intro/IntroView.swift b/Projects/Feature/FeatureIntro/Sources/Intro/IntroView.swift similarity index 98% rename from Projects/Feature/FeatureLogin/Sources/Intro/IntroView.swift rename to Projects/Feature/FeatureIntro/Sources/Intro/IntroView.swift index 4d0ef886..74e865e9 100644 --- a/Projects/Feature/FeatureLogin/Sources/Intro/IntroView.swift +++ b/Projects/Feature/FeatureIntro/Sources/Intro/IntroView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import FeatureLogin public struct IntroView: View { /// - Properties diff --git a/Projects/Feature/FeatureLogin/Sources/Splash/SplashFeature.swift b/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift similarity index 100% rename from Projects/Feature/FeatureLogin/Sources/Splash/SplashFeature.swift rename to Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift diff --git a/Projects/Feature/FeatureLogin/Sources/Splash/SplashView.swift b/Projects/Feature/FeatureIntro/Sources/Splash/SplashView.swift similarity index 100% rename from Projects/Feature/FeatureLogin/Sources/Splash/SplashView.swift rename to Projects/Feature/FeatureIntro/Sources/Splash/SplashView.swift diff --git a/Projects/Feature/FeatureIntroDemo/Resources/LaunchScreen.storyboard b/Projects/Feature/FeatureIntroDemo/Resources/LaunchScreen.storyboard new file mode 100644 index 00000000..f1721f80 --- /dev/null +++ b/Projects/Feature/FeatureIntroDemo/Resources/LaunchScreen.storyboard @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift b/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift new file mode 100644 index 00000000..31c4cda1 --- /dev/null +++ b/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift @@ -0,0 +1,17 @@ +// +// App.stencil.swift +// ProjectDescriptionHelpers +// +// Created by 김도형 on 6/16/24. +// + +import SwiftUI + +@main +struct FeatureIntroDemoApp: App { + var body: some Scene { + WindowGroup { + // TODO: 루트 뷰 추가 + } + } +} diff --git a/Projects/Feature/FeatureIntroTests/Resources/info.plist b/Projects/Feature/FeatureIntroTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureIntroTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureIntroTests/Sources/FeatureIntroTests.swift b/Projects/Feature/FeatureIntroTests/Sources/FeatureIntroTests.swift new file mode 100644 index 00000000..c0aec3ce --- /dev/null +++ b/Projects/Feature/FeatureIntroTests/Sources/FeatureIntroTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureIntro + +final class FeatureIntroTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift b/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift index f6400565..885b091a 100644 --- a/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift +++ b/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift @@ -155,12 +155,10 @@ extension RemindView { .padding(12) } .frame(width: 216, height: 194) - .background { - if let url = URL(string: content.thumbNail) { - recommendedContentCellImage(url: url, contentId: content.id) - } else { - imagePlaceholder - } + .background(ifLet: URL(string: content.thumbNail)) { url in + recommendedContentCellImage(url: url, contentId: content.id) + } else: { + imagePlaceholder } .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .clipped() diff --git a/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift b/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift index daa6c95d..9d09d8ee 100644 --- a/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift +++ b/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift @@ -9,19 +9,26 @@ import SwiftUI import ComposableArchitecture import FeatureRemind +import FeatureIntro @main struct FeatureRemindDemoApp: App { var body: some Scene { WindowGroup { // TODO: 루트 뷰 추가 - NavigationStack { - RemindView( - store: .init( - initialState: .init(), - reducer: { RemindFeature() } + + DemoView(store: .init( + initialState: .init(), + reducer: { DemoFeature() } + )) { + NavigationStack { + RemindView( + store: .init( + initialState: .init(), + reducer: { RemindFeature() } + ) ) - ) + } } } } diff --git a/Projects/Util/Sources/Extension/View+Extension.swift b/Projects/Util/Sources/Extension/View+Extension.swift index d0e7c9b0..b5b3f44d 100644 --- a/Projects/Util/Sources/Extension/View+Extension.swift +++ b/Projects/Util/Sources/Extension/View+Extension.swift @@ -7,8 +7,9 @@ import SwiftUI -extension View { - public func dismissKeyboard( +public extension View { + @ViewBuilder + func dismissKeyboard( focused: FocusState.Binding ) -> some View { self @@ -23,7 +24,8 @@ extension View { } } - public func dismissKeyboard( + @ViewBuilder + func dismissKeyboard( focused: FocusState.Binding ) -> some View { self @@ -37,4 +39,72 @@ extension View { } } } + + @ViewBuilder + func overlay( + `if` condition: Bool, + alignment: Alignment = .center, + @ViewBuilder content: () -> some View, + @ViewBuilder `else`: () -> some View = { EmptyView() } + ) -> some View { + self + .overlay(alignment: alignment) { + if condition { + content() + } else { + `else`() + } + } + } + + @ViewBuilder + func overlay( + ifLet optional: T?, + alignment: Alignment = .center, + @ViewBuilder content: (T) -> some View, + @ViewBuilder `else`: () -> some View = { EmptyView() } + ) -> some View { + self + .overlay(alignment: alignment) { + if let optional { + content(optional) + } else { + `else`() + } + } + } + + @ViewBuilder + func background( + `if` condition: Bool, + alignment: Alignment = .center, + @ViewBuilder content: () -> some View, + @ViewBuilder `else`: () -> some View = { EmptyView() } + ) -> some View { + self + .background(alignment: alignment) { + if condition { + content() + } else { + `else`() + } + } + } + + @ViewBuilder + func background( + ifLet optional: T?, + alignment: Alignment = .center, + @ViewBuilder content: (T) -> some View, + @ViewBuilder `else`: () -> some View = { EmptyView() } + ) -> some View { + self + .background(alignment: alignment) { + if let optional { + content(optional) + } else { + `else`() + } + } + } } diff --git a/Templates/Pokit_TCA.xctemplate/___FILEBASENAME___Feature.swift b/Templates/Pokit_TCA.xctemplate/___FILEBASENAME___Feature.swift index 24e3d927..aefa4481 100644 --- a/Templates/Pokit_TCA.xctemplate/___FILEBASENAME___Feature.swift +++ b/Templates/Pokit_TCA.xctemplate/___FILEBASENAME___Feature.swift @@ -95,4 +95,20 @@ private extension ___VARIABLE_sceneName___Feature { 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) + } + } } diff --git a/Tuist/ProjectDescriptionHelpers/Feature.swift b/Tuist/ProjectDescriptionHelpers/Feature.swift index 7c2e86cf..3a220249 100644 --- a/Tuist/ProjectDescriptionHelpers/Feature.swift +++ b/Tuist/ProjectDescriptionHelpers/Feature.swift @@ -20,6 +20,7 @@ public enum Feature: String, CaseIterable { case contentList = "ContentList" case categorySharing = "CategorySharing" case contentCard = "ContentCard" + case intro = "Intro" public var target: Target { return .makeTarget( @@ -35,15 +36,21 @@ public enum Feature: String, CaseIterable { } public var demoTarget: Target { + var dependencies: [TargetDependency] = [.target(self.target)] + if self != .login && self != .intro { + dependencies.append( + .project(target: "FeatureIntro", path: .relativeToRoot("Projects/Feature")) + ) + } + return .makeTarget( name: "Feature\(self.rawValue)Demo", product: .app, bundleName: "Feature.\(self.rawValue)Demo", infoPlist: .file(path: .relativeToRoot("Projects/App/Resources/Pokit-info.plist")), resources: ["Feature\(self.rawValue)Demo/Resources/**"], - dependencies: [ - .target(self.target) - ] + entitlements: .file(path: .relativeToRoot("Projects/App/ShareExtension/ShareExtension.entitlements")), + dependencies: dependencies ) } @@ -89,6 +96,10 @@ public enum Feature: String, CaseIterable { .project(target: "FeatureContentCard", path: .relativeToRoot("Projects/Feature")) ] case .contentCard: return [] + case .intro: + return [ + .project(target: "FeatureLogin", path: .relativeToRoot("Projects/Feature")) + ] } } } diff --git a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift index 486c516f..91bfa436 100644 --- a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift @@ -14,6 +14,7 @@ public extension Target { bundleName: String, infoPlist: InfoPlist? = nil, resources: ResourceFileElements? = nil, + entitlements: Entitlements? = nil, dependencies: [TargetDependency] ) -> Target { return .target( @@ -25,6 +26,7 @@ public extension Target { infoPlist: infoPlist, sources: ["\(name)/Sources/**"], resources: resources, + entitlements: entitlements, dependencies: dependencies, settings: .settings() ) diff --git a/graph.png b/graph.png index d8bc8e12..4f6618e4 100644 Binary files a/graph.png and b/graph.png differ