diff --git a/.github/workflows/hotfix.yml b/.github/workflows/appstore_release.yml similarity index 74% rename from .github/workflows/hotfix.yml rename to .github/workflows/appstore_release.yml index 6c7b4ebf..bd63256e 100644 --- a/.github/workflows/hotfix.yml +++ b/.github/workflows/appstore_release.yml @@ -1,11 +1,12 @@ # This workflow will build a Swift project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift -name: hotfix +name: appstore-release on: - push: - branches: [ "hotfix/*" ] + pull_request: + branches: + - main jobs: build: @@ -17,12 +18,12 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '15.4' + xcode-version: latest-stable - # - uses: shimataro/ssh-key-action@v2 - # with: - # key: ${{ secrets.SSH_KEY }} - # known_hosts: ${{ secrets.KNOWN_HOSTS }} + - uses: shimataro/ssh-key-action@v2 + with: + key: ${{ secrets.SSH_KEY }} + known_hosts: ${{ secrets.KNOWN_HOSTS }} - name: initial mise run: | @@ -36,10 +37,10 @@ jobs: - name: Generate Project env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} - MATCH_URL: ${{ secrets.MATCH_URL }} - APP_STORE_CONNECT_TEAM_ID: ${{ secrets.APP_STORE_CONNECT_TEAM_ID }} DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} - run: fastlane generate + run: | + fastlane appstore_profile + make release - name: Build Archive env: @@ -48,9 +49,9 @@ jobs: APP_STORE_CONNECT_API: ${{ secrets.APP_STORE_CONNECT_API }} run: fastlane archive - - name: Beta Release + - name: Appstore Release env: APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_STORE_CONNECT_API: ${{ secrets.APP_STORE_CONNECT_API }} - run: fastlane release + run: fastlane appstore_release diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index e0f7bc91..d27957cc 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -5,7 +5,10 @@ name: build-test on: pull_request: - branches: [ "develop" ] + branches: + - develop + + workflow_dispatch: jobs: build: @@ -17,7 +20,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '15.4' + xcode-version: latest-stable - uses: shimataro/ssh-key-action@v2 with: @@ -36,10 +39,10 @@ jobs: - name: Test Generate env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} - MATCH_URL: ${{ secrets.MATCH_URL }} - APP_STORE_CONNECT_TEAM_ID: ${{ secrets.APP_STORE_CONNECT_TEAM_ID }} DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} - run: fastlane test + run: | + fastlane development_profile + make test - name: Build Test run: fastlane build diff --git a/.github/workflows/workflow_test.yml b/.github/workflows/develop_hotfix.yml similarity index 61% rename from .github/workflows/workflow_test.yml rename to .github/workflows/develop_hotfix.yml index 606764de..01172def 100644 --- a/.github/workflows/workflow_test.yml +++ b/.github/workflows/develop_hotfix.yml @@ -1,14 +1,17 @@ # This workflow will build a Swift project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift -name: workflow_test +name: develop-hotfix on: push: - branches: [ "workflow/*" ] + branches: [ "develop" ] + + workflow_dispatch: jobs: build: + if: startsWith(github.event.head_commit.message, '[hotfix]') runs-on: macos-latest steps: @@ -17,12 +20,12 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '15.4' + xcode-version: latest-stable - # - uses: shimataro/ssh-key-action@v2 - # with: - # key: ${{ secrets.SSH_KEY }} - # known_hosts: ${{ secrets.KNOWN_HOSTS }} + - uses: shimataro/ssh-key-action@v2 + with: + key: ${{ secrets.SSH_KEY }} + known_hosts: ${{ secrets.KNOWN_HOSTS }} - name: initial mise run: | @@ -33,24 +36,13 @@ jobs: - name: initial tuist run: mise install tuist - - name: Test Generate - env: - MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} - MATCH_URL: ${{ secrets.MATCH_URL }} - APP_STORE_CONNECT_TEAM_ID: ${{ secrets.APP_STORE_CONNECT_TEAM_ID }} - DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} - run: fastlane test - - - name: Build Test - run: fastlane build - - - name: Release Generate + - name: Generate Project env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} - MATCH_URL: ${{ secrets.MATCH_URL }} - APP_STORE_CONNECT_TEAM_ID: ${{ secrets.APP_STORE_CONNECT_TEAM_ID }} DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} - run: fastlane generate + run: | + fastlane appstore_profile + make release - name: Build Archive env: @@ -59,9 +51,9 @@ jobs: APP_STORE_CONNECT_API: ${{ secrets.APP_STORE_CONNECT_API }} run: fastlane archive - - name: Beta Release + - name: Testflight Release env: APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_STORE_CONNECT_API: ${{ secrets.APP_STORE_CONNECT_API }} - run: fastlane release + run: fastlane testflight_release diff --git a/.github/workflows/release_update.yml b/.github/workflows/release_update.yml new file mode 100644 index 00000000..8104f099 --- /dev/null +++ b/.github/workflows/release_update.yml @@ -0,0 +1,31 @@ +# This workflow will build a Swift project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift + +name: release-update + +on: + pull_request: + branches: + - main + types: + - closed + +jobs: + release-update: + if: ${{ github.event.pull_request.merged == true }} + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install GitHub CLI + run: sudo apt-get install gh + + - name: Update Release + env: + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_API: ${{ secrets.APP_STORE_CONNECT_API }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: fastlane update_github_release diff --git a/.github/workflows/testflight_release.yml b/.github/workflows/testflight_release.yml index 0ccfd05e..aaf83977 100644 --- a/.github/workflows/testflight_release.yml +++ b/.github/workflows/testflight_release.yml @@ -1,14 +1,20 @@ # This workflow will build a Swift project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift -name: testflight_release +name: testflight-release on: - push: - branches: [ "develop" ] + pull_request: + branches: + - develop + types: + - closed + + workflow_dispatch: jobs: build: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true }} runs-on: macos-latest steps: @@ -17,7 +23,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '15.4' + xcode-version: latest-stable - uses: shimataro/ssh-key-action@v2 with: @@ -36,10 +42,10 @@ jobs: - name: Generate Project env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} - MATCH_URL: ${{ secrets.MATCH_URL }} - APP_STORE_CONNECT_TEAM_ID: ${{ secrets.APP_STORE_CONNECT_TEAM_ID }} DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} - run: fastlane generate + run: | + fastlane appstore_profile + make release - name: Build Archive env: @@ -48,9 +54,9 @@ jobs: APP_STORE_CONNECT_API: ${{ secrets.APP_STORE_CONNECT_API }} run: fastlane archive - - name: Beta Release + - name: Testflight Release env: APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_STORE_CONNECT_API: ${{ secrets.APP_STORE_CONNECT_API }} - run: fastlane release + run: fastlane testflight_release diff --git a/Makefile b/Makefile index 8a090c56..372403c0 100644 --- a/Makefile +++ b/Makefile @@ -52,13 +52,17 @@ download-privates: @if [ ! -d "Pokit_iOS_Private" ]; then \ git clone git@github.com:stealmh/Pokit_iOS_Private.git; \ fi - @if [ -f "Pokit_iOS_Private/xcconfig/Secret.xcconfig" ]; then \ + @if [ -f "Pokit_iOS_Private/xcconfig/Debug.xcconfig" ] && [ -f "Pokit_iOS_Private/xcconfig/Release.xcconfig" ]; then \ mkdir -p xcconfig; \ - cp Pokit_iOS_Private/xcconfig/Secret.xcconfig xcconfig/Secret.xcconfig; \ + cp Pokit_iOS_Private/xcconfig/Debug.xcconfig xcconfig/Debug.xcconfig; \ + cp Pokit_iOS_Private/xcconfig/Release.xcconfig xcconfig/Release.xcconfig; \ + cp Pokit_iOS_Private/xcconfig/Debug-Share.xcconfig xcconfig/Debug-Share.xcconfig; \ + cp Pokit_iOS_Private/xcconfig/Release-Share.xcconfig xcconfig/Release-Share.xcconfig; \ cp Pokit_iOS_Private/auth/AuthKey.p8 Projects/CoreKit/Resources/AuthKey.p8; \ cp Pokit_iOS_Private/GoogleService-Info.plist Projects/App/Resources/GoogleService-Info.plist; \ + cp Pokit_iOS_Private/GoogleService-Info.plist Projects/App/ShareExtension/Resources/GoogleService-Info.plist; \ rm -rf Pokit_iOS_Private; \ - echo "✅ Secret 파일을 성공적으로 다운로드하고 Pokit_iOS_Private 폴더를 삭제했습니다."; \ + echo "✅ Debug.xcconfig와 Release.xcconfig 파일을 성공적으로 다운로드하고 Pokit_iOS_Private 폴더를 삭제했습니다."; \ else \ - echo "❌ Secret.xcconfig 파일을 찾을 수 없습니다."; \ + echo "❌ Debug.xcconfig 또는 Release.xcconfig 파일을 찾을 수 없습니다." && exit 1; \ fi diff --git a/Projects/App/AppTests/Resources/info.plist b/Projects/App/AppTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/App/AppTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/App/AppTests/Sources/AppTests.swift b/Projects/App/AppTests/Sources/AppTests.swift new file mode 100644 index 00000000..edcec259 --- /dev/null +++ b/Projects/App/AppTests/Sources/AppTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import App + +final class AppTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 7b965c96..51ce1d00 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -15,6 +15,74 @@ let features: [TargetDependency] = Feature.allCases.map { feature in .project(target: "Feature\(feature.rawValue)", path: .relativeToRoot("Projects/Feature")) } +let shareExtensionTarget: Target = .target( + name: "ShareExtension", + destinations: .appDestinations, + product: .appExtension, + bundleId: "com.pokitmons.pokit.ShareExtension", + deploymentTargets: .appMinimunTarget, + infoPlist: .file(path: .relativeToRoot("Projects/App/ShareExtension/Info.plist")), + sources: ["ShareExtension/Sources/**"], + resources: ["ShareExtension/Resources/**"], + entitlements: .file(path: .relativeToRoot("Projects/App/ShareExtension/ShareExtension.entitlements")), + dependencies: [ + .project(target: "FeatureLogin", path: .relativeToRoot("Projects/Feature")), + .project(target: "FeatureContentSetting", path: .relativeToRoot("Projects/Feature")), + .project(target: "FeatureCategorySetting", path: .relativeToRoot("Projects/Feature")) + ], + settings: .settings( + base: [ + "OTHER_LDFLAGS": "$(inherited) -ObjC", + "CODE_SIGN_STYLE": "Manual" + ], + configurations: [ + .debug(name: "Debug", xcconfig: .relativeToRoot("xcconfig/Debug-Share.xcconfig")), + .release( + name: "Release", + settings: ["CODE_SIGN_IDENTITY": "Apple Distribution"], + xcconfig: .relativeToRoot("xcconfig/Release-Share.xcconfig") + ) + ] + ) +) + +let projectTarget: Target = .target( + name: "App", + destinations: .appDestinations, + product: .app, + bundleId: "com.pokitmons.pokit", + deploymentTargets: .appMinimunTarget, + infoPlist: .file(path: .relativeToRoot("Projects/App/Resources/Pokit-info.plist")), + sources: ["Sources/**"], + resources: ["Resources/**"], + entitlements: .file(path: .relativeToRoot("Projects/App/Resources/Pokit-iOS.entitlements")), + dependencies: features + [ + // TODO: 의존성 추가 + .external(name: "FirebaseMessaging"), + .target(shareExtensionTarget) + ], + settings: .settings( + .release( + name: "Release", + settings: [ + "CODE_SIGN_IDENTITY": "Apple Distribution" + ], + xcconfig: .relativeToRoot("xcconfig/Release.xcconfig") + ) + ) +) + +let appTestTarget: Target = .makeTarget( + name: "AppTests", + product: .unitTests, + bundleName: "AppTests", + infoPlist: .dictionary(["ENABLE_TESTING_SEARCH_PATHS": "YES"]), + resources: ["AppTests/Resources/**"], + dependencies: [ + .target(projectTarget) + ] +) + let project = Project( name: "App", options: .options( @@ -22,33 +90,8 @@ let project = Project( developmentRegion: "ko" ), targets: [ - .target( - name: "App", - destinations: .appDestinations, - product: .app, - bundleId: "com.pokitmons.pokit", - deploymentTargets: .appMinimunTarget, - infoPlist: .file(path: .relativeToRoot("Projects/App/Resources/Pokit-info.plist")), - sources: ["Sources/**"], - resources: ["Resources/**"], - entitlements: .file(path: .relativeToRoot("Projects/App/Resources/Pokit-iOS.entitlements")), - dependencies: features + [ - // TODO: 의존성 추가 - .external(name: "FirebaseMessaging") - ], - settings: .settings( - base: [ - "OTHER_LDFLAGS": "$(inherited) -ObjC", - "CODE_SIGN_IDENTITY": "Apple Distribution", - "PROVISIONING_PROFILE_SPECIFIER": "match AppStore com.pokitmons.pokit 1721720816", - "PROVISIONING_PROFILE": "match AppStore com.pokitmons.pokit 1721720816", - "DEVELOPMENT_TEAM": "\(developmentTeam ?? "")" - ], - configurations: [ - .debug(name: "Debug", xcconfig: .relativeToRoot("xcconfig/Secret.xcconfig")), - .release(name: "Release", xcconfig: .relativeToRoot("xcconfig/Secret.xcconfig")) - ] - ) - ) + projectTarget, + appTestTarget, + shareExtensionTarget ] ) diff --git a/Projects/App/Resources/Pokit-iOS.entitlements b/Projects/App/Resources/Pokit-iOS.entitlements index 80b5221d..11bd0e65 100644 --- a/Projects/App/Resources/Pokit-iOS.entitlements +++ b/Projects/App/Resources/Pokit-iOS.entitlements @@ -8,5 +8,13 @@ Default + com.apple.security.application-groups + + group.com.pokitmons.pokit + + keychain-access-groups + + $(AppIdentifierPrefix)group.com.pokitmons.pokit + diff --git a/Projects/App/Resources/Pokit-info.plist b/Projects/App/Resources/Pokit-info.plist index 7368d7ef..af5f75f4 100644 --- a/Projects/App/Resources/Pokit-info.plist +++ b/Projects/App/Resources/Pokit-info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.3 + 1.0.4 CFBundleURLTypes diff --git a/Projects/App/ShareExtension/Info.plist b/Projects/App/ShareExtension/Info.plist new file mode 100644 index 00000000..8e36808f --- /dev/null +++ b/Projects/App/ShareExtension/Info.plist @@ -0,0 +1,54 @@ + + + + + AppleKeyID + $(AppleKeyID) + CFBundleDisplayName + Pokit + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + Pokit + CFBundleShortVersionString + 1.0.4 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + $(GIDClientID_reversed) + + + + CFBundleVersion + 1 + GIDClientID + $(GIDClientID) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebPageWithMaxCount + 1000 + NSExtensionActivationSupportsWebURLWithMaxCount + 1000 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + TeamID + $(TeamID) + + diff --git a/Projects/App/ShareExtension/Resources/Resource.swift b/Projects/App/ShareExtension/Resources/Resource.swift new file mode 100644 index 00000000..3780fc99 --- /dev/null +++ b/Projects/App/ShareExtension/Resources/Resource.swift @@ -0,0 +1,8 @@ +// +// Source.stencil.swift +// Manifests +// +// Created by 김도형 on 6/16/24. +// + +import Foundation diff --git a/Projects/App/ShareExtension/ShareExtension.entitlements b/Projects/App/ShareExtension/ShareExtension.entitlements new file mode 100644 index 00000000..17445d40 --- /dev/null +++ b/Projects/App/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.developer.applesignin + + Default + + com.apple.security.application-groups + + group.com.pokitmons.pokit + + keychain-access-groups + + $(AppIdentifierPrefix)group.com.pokitmons.pokit + + + diff --git a/Projects/App/ShareExtension/Sources/Base.lproj/MainInterface.storyboard b/Projects/App/ShareExtension/Sources/Base.lproj/MainInterface.storyboard new file mode 100644 index 00000000..286a5089 --- /dev/null +++ b/Projects/App/ShareExtension/Sources/Base.lproj/MainInterface.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/App/ShareExtension/Sources/ShareRootFeature.swift b/Projects/App/ShareExtension/Sources/ShareRootFeature.swift new file mode 100644 index 00000000..b63b2e44 --- /dev/null +++ b/Projects/App/ShareExtension/Sources/ShareRootFeature.swift @@ -0,0 +1,211 @@ +// +// ShareRootFeature.swift +// ShareExtension +// +// Created by 김도형 on 10/17/24. +// + +import UIKit +import UniformTypeIdentifiers + +import ComposableArchitecture +import FeatureLogin +import FeatureContentSetting +import FeatureCategorySetting +import CoreKit +import Util +import Social + +@Reducer +struct ShareRootFeature { + @Dependency(SocialLoginClient.self) + private var socialLogin + + @ObservableState + struct State { + var intro: IntroFeature.State? = .init() + var contentSetting: ContentSettingFeature.State? = nil + var path = StackState() + + var url: URL? + var context: NSExtensionContext? + var controller: SLComposeServiceViewController? + } + + enum Action { + case intro(IntroFeature.Action) + case contentSetting(ContentSettingFeature.Action) + case inner(InnerAction) + case async(AsyncAction) + case scope(ScopeAction) + case controller(ControllerAction) + case path(StackActionOf) + + enum InnerAction { + case dismiss + case URL_파싱_수행_반영(URL?) + } + enum AsyncAction { + case URL_파싱_수행 + } + enum ScopeAction { + case intro(IntroFeature.Action) + case contentSetting(ContentSettingFeature.Action) + case categorySetting(PokitCategorySettingFeature.Action) + } + enum ControllerAction { + case viewDidLoad(SLComposeServiceViewController?, NSExtensionContext?) + case viewDidAppear + case presentationControllerDidDismiss + } + } + + private func core(into state: inout State, action: Action) -> Effect { + switch action { + case .intro(let introAction): + return .send(.scope(.intro(introAction))) + case .contentSetting(let contentSettingAction): + return .send(.scope(.contentSetting(contentSettingAction))) + /// - 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) + /// - Controller + case .controller(let controllerAction): + return handleControllerAction(controllerAction, state: &state) + /// - Path + case .path(let pathAction): + return handlePathAction(pathAction, state: &state) + } + } + + func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { + switch action { + case let .URL_파싱_수행_반영(url): + state.url = url + return .none + case .dismiss: + state.controller?.dismiss(animated: true) { [context = state.context] in + /// 🚨 Error Case [1]: 사용자가 취소한 경우 + let error = NSError( + domain: "com.pokitmons.pokit.ShareExtension", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "User cancelled the action."] + ) + context?.cancelRequest(withError: error) + } + return .none + } + } + + func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { + switch action { + case .URL_파싱_수행: + guard let item = state.context?.inputItems.first as? NSExtensionItem, + let itemProvider = item.attachments?.first else { + return .none + } + + return .run { send in + var urlItem: (any NSSecureCoding)? = nil + + if itemProvider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + urlItem = try await itemProvider.loadItem( + forTypeIdentifier: UTType.url.identifier + ) + let url = urlItem as? URL + await send(.inner(.URL_파싱_수행_반영(url))) + + } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + /// 🚨 Error Case [1]: 유튜브 링크같이 url자체로 파싱이 안되는 경우 + urlItem = try await itemProvider.loadItem( + forTypeIdentifier: UTType.plainText.identifier + ) + guard let urlString = urlItem as? String else { return } + await send(.inner(.URL_파싱_수행_반영(URL(string: urlString)))) + } + } + } + } + + func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { + switch action { + case .intro(.delegate(.moveToTab)): + state.contentSetting = .init( + urlText: state.url?.absoluteString, + isShareExtension: true + ) + state.intro = nil + return .none + case .intro: + return .none + case .contentSetting(.delegate(.저장하기_완료)): + state.controller?.dismiss(animated: true) { [context = state.context] in + context?.completeRequest(returningItems: []) + } + return .none + case .contentSetting(.delegate(.dismiss)): + return .send(.inner(.dismiss)) + case .contentSetting(.delegate(.포킷추가하기)): + state.path.append(.categorySetting(PokitCategorySettingFeature.State(type: .추가))) + return .none + case .contentSetting: + return .none + case .categorySetting(.delegate(.settingSuccess)): + state.path.removeLast() + return .none + case .categorySetting: + return .none + } + } + + func handleControllerAction(_ action: Action.ControllerAction, state: inout State) -> Effect { + switch action { + case let .viewDidLoad(controller, context): + state.context = context + state.controller = controller + socialLogin.setRootViewController(controller) + return .send(.async(.URL_파싱_수행)) + case .viewDidAppear: + state.controller?.textView.resignFirstResponder() + return .none + case .presentationControllerDidDismiss: + /// 🚨 Error Case [2]: 사용자가 시트를 내려서 취소한 경우 + let error = NSError( + domain: "com.pokitmons.pokit.ShareExtension", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "User cancelled the action."] + ) + state.context?.cancelRequest(withError: error) + return .none + } + } + + func handlePathAction(_ action: StackActionOf, state: inout State) -> Effect { + switch action { + case let .element(id: _, action: .categorySetting(categorySettingAction)): + return .send(.scope(.categorySetting(categorySettingAction))) + case .element, .popFrom, .push: + return .none + } + } + + public var body: some ReducerOf { + Reduce(self.core) + .ifLet(\.intro, action: \.intro) { IntroFeature() } + .ifLet(\.contentSetting, action: \.contentSetting) { ContentSettingFeature() } + .forEach(\.path, action: \.path) + } +} + +extension ShareRootFeature { + @Reducer + enum Path { + case categorySetting(PokitCategorySettingFeature) + } +} diff --git a/Projects/App/ShareExtension/Sources/ShareRootView.swift b/Projects/App/ShareExtension/Sources/ShareRootView.swift new file mode 100644 index 00000000..f538be53 --- /dev/null +++ b/Projects/App/ShareExtension/Sources/ShareRootView.swift @@ -0,0 +1,52 @@ +// +// ShareRootView.swift +// ShareExtension +// +// Created by 김도형 on 10/17/24. +// + +import SwiftUI + +import ComposableArchitecture +import FeatureLogin +import FeatureContentSetting +import FeatureCategorySetting +import DSKit + +struct ShareRootView: View { + /// - Properties + @Perception.Bindable + private var store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + WithPerceptionTracking { + Group { + if let store = store.scope(state: \.intro, action: \.intro) { + IntroView(store: store) + } else if let store = store.scope(state: \.contentSetting, action: \.contentSetting) { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + ContentSettingView(store: store) + } destination: { path in + switch path.case { + case let .categorySetting(store): + PokitCategorySettingView(store: store) + } + } + } + } + } + } +} + +#Preview { + ShareRootView( + store: Store( + initialState: .init(), + reducer: { ShareRootFeature() } + ) + ) +} diff --git a/Projects/App/ShareExtension/Sources/ShareViewController.swift b/Projects/App/ShareExtension/Sources/ShareViewController.swift new file mode 100644 index 00000000..a7e218bf --- /dev/null +++ b/Projects/App/ShareExtension/Sources/ShareViewController.swift @@ -0,0 +1,41 @@ +// +// ShareViewController.swift +// ShareExtension +// +// Created by 김도형 on 10/16/24. +// + +import SwiftUI +import Social + +import ComposableArchitecture +import FeatureContentSetting + +class ShareViewController: SLComposeServiceViewController { + let store = Store(initialState: ShareRootFeature.State()) { + ShareRootFeature() + } + + override func viewDidLoad() { + super.viewDidLoad() + + let hostingController = UIHostingController( + rootView: ShareRootView(store: store) + ) + hostingController.presentationController?.delegate = self + hostingController.modalPresentationStyle = .automatic + present(hostingController, animated: true) + + store.send(.controller(.viewDidLoad(self, extensionContext))) + } + + override func viewDidAppear(_ animated: Bool) { + store.send(.controller(.viewDidAppear)) + } +} + +extension ShareViewController: UIAdaptivePresentationControllerDelegate { + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + store.send(.controller(.presentationControllerDidDismiss)) + } +} diff --git a/Projects/App/Sources/AppDelegate/AppDelegateFeature.swift b/Projects/App/Sources/AppDelegate/AppDelegateFeature.swift index d5fdbcdd..ace38842 100644 --- a/Projects/App/Sources/AppDelegate/AppDelegateFeature.swift +++ b/Projects/App/Sources/AppDelegate/AppDelegateFeature.swift @@ -14,9 +14,9 @@ import KakaoSDKCommon @Reducer public struct AppDelegateFeature { - @Dependency(\.userNotifications) var userNotifications - @Dependency(\.remoteNotifications.register) var registerForRemoteNotifications - @Dependency(\.userDefaults) var userDefaults + @Dependency(UserNotificationClient.self) var userNotifications + @Dependency(RemoteNotificationsClient.self) var registerForRemoteNotifications + @Dependency(UserDefaultsClient.self) var userDefaults @ObservableState public struct State { @@ -64,7 +64,7 @@ public struct AppDelegateFeature { else { return } default: return } - await self.registerForRemoteNotifications() + await self.registerForRemoteNotifications.register() } } } diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift index c76bacb4..3da32829 100644 --- a/Projects/App/Sources/MainTab/MainTabFeature.swift +++ b/Projects/App/Sources/MainTab/MainTabFeature.swift @@ -17,11 +17,11 @@ import CoreKit @Reducer public struct MainTabFeature { /// - Dependency - @Dependency(\.pasteboard) + @Dependency(PasteboardClient.self) private var pasteBoard - @Dependency(\.categoryClient) + @Dependency(CategoryClient.self) private var categoryClient - @Dependency(\.userDefaults) + @Dependency(UserDefaultsClient.self) private var userDefaults /// - State @ObservableState diff --git a/Projects/App/Sources/MainTab/MainTabFeatureView.swift b/Projects/App/Sources/MainTab/MainTabFeatureView.swift index 5fcc9cee..abe6d5d6 100644 --- a/Projects/App/Sources/MainTab/MainTabFeatureView.swift +++ b/Projects/App/Sources/MainTab/MainTabFeatureView.swift @@ -159,15 +159,15 @@ private extension MainTabView { PokitHeaderItems(placement: .trailing) { PokitToolbarButton( .icon(.search), - action: { store.send(.pokit(.view(.searchButtonTapped))) } + action: { store.send(.pokit(.view(.검색_버튼_눌렀을때))) } ) PokitToolbarButton( .icon(.bell), - action: { store.send(.pokit(.view(.alertButtonTapped))) } + action: { store.send(.pokit(.view(.알람_버튼_눌렀을때))) } ) PokitToolbarButton( .icon(.setup), - action: { store.send(.pokit(.view(.settingButtonTapped))) } + action: { store.send(.pokit(.view(.설정_버튼_눌렀을때))) } ) } } @@ -185,11 +185,11 @@ private extension MainTabView { PokitHeaderItems(placement: .trailing) { PokitToolbarButton( .icon(.search), - action: { store.send(.remind(.view(.searchButtonTapped))) } + action: { store.send(.remind(.view(.검색_버튼_눌렀을때))) } ) PokitToolbarButton( .icon(.bell), - action: { store.send(.remind(.view(.bellButtonTapped))) } + action: { store.send(.remind(.view(.알림_버튼_눌렀을때))) } ) } } diff --git a/Projects/App/Sources/MainTab/MainTabPath.swift b/Projects/App/Sources/MainTab/MainTabPath.swift index d8bea9c7..d61bc262 100644 --- a/Projects/App/Sources/MainTab/MainTabPath.swift +++ b/Projects/App/Sources/MainTab/MainTabPath.swift @@ -145,7 +145,7 @@ public extension MainTabFeature { case .pokit: return .send(.pokit(.delegate(.미분류_카테고리_컨텐츠_조회))) case .remind: - return .send(.remind(.delegate(.컨텐츠목록_조회))) + return .send(.remind(.delegate(.컨텐츠_상세보기_delegate_위임))) } } switch lastPath { diff --git a/Projects/App/Sources/Root/RootFeature.swift b/Projects/App/Sources/Root/RootFeature.swift index 38310860..a31a69de 100644 --- a/Projects/App/Sources/Root/RootFeature.swift +++ b/Projects/App/Sources/Root/RootFeature.swift @@ -8,12 +8,13 @@ import Foundation import ComposableArchitecture +import FeatureLogin import CoreKit @Reducer public struct RootFeature { - @Dependency(\.userDefaults) var userDefaults - @Dependency(\.userClient) var userClient + @Dependency(UserDefaultsClient.self) var userDefaults + @Dependency(UserClient.self) var userClient @Reducer(state: .equatable) public enum Destination { diff --git a/Projects/App/Sources/Root/RootView.swift b/Projects/App/Sources/Root/RootView.swift index 0f0024d4..748e5057 100644 --- a/Projects/App/Sources/Root/RootView.swift +++ b/Projects/App/Sources/Root/RootView.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture +import FeatureLogin import DSKit public struct RootView: View { diff --git a/Projects/CoreKit/Project.swift b/Projects/CoreKit/Project.swift index ea0dd934..2679bc2b 100644 --- a/Projects/CoreKit/Project.swift +++ b/Projects/CoreKit/Project.swift @@ -37,19 +37,10 @@ let coreKit: Target = .target( .external(name: "KakaoSDKShare"), .external(name: "KakaoSDKTemplate"), ], - settings: .settings( - base: [ - "OTHER_LDFLAGS": "$(inherited) -ObjC", - ], - configurations: [ - .debug(name: "Debug", xcconfig: .relativeToRoot("xcconfig/Secret.xcconfig")), - .release(name: "Release", xcconfig: .relativeToRoot("xcconfig/Secret.xcconfig")) - ] - ) + settings: .settings() ) let project = Project( name: "CoreKit", - settings: .settings, targets: [coreKit] ) diff --git a/Projects/CoreKit/Sources/CoreNetwork/TokenInterceptor.swift b/Projects/CoreKit/Sources/CoreNetwork/TokenInterceptor.swift index ea05b643..037792af 100644 --- a/Projects/CoreKit/Sources/CoreNetwork/TokenInterceptor.swift +++ b/Projects/CoreKit/Sources/CoreNetwork/TokenInterceptor.swift @@ -13,8 +13,8 @@ import Dependencies /// AccessToken값을 헤더에 넣어주거나, refreshToken으로 Intercept해주는 Interceptor public final class TokenInterceptor: RequestInterceptor { - @Dependency(\.keychain) var keychain - @Dependency(\.authClient) var authClient + @Dependency(KeychainClient.self) var keychain + @Dependency(AuthClient.self) var authClient static let shared = TokenInterceptor() private init() {} diff --git a/Projects/CoreKit/Sources/Data/Client/KakaoSDK/Share/KakaoShareClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/KakaoSDK/Share/KakaoShareClient+LiveKey.swift new file mode 100644 index 00000000..a27d8a0a --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/KakaoSDK/Share/KakaoShareClient+LiveKey.swift @@ -0,0 +1,82 @@ +// +// KakaoShareClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import UIKit.UIApplication + +import Dependencies +import KakaoSDKCommon +import KakaoSDKShare +import KakaoSDKTemplate + +extension KakaoShareClient: DependencyKey { + public static var liveValue: KakaoShareClient { + return Self( + 카테고리_카카오톡_공유: { model in + /// 딥링크 + let appLink = Link( + androidExecutionParams: [ + "categoryId": "\(model.categoryId)" + ], iosExecutionParams: [ + "categoryId": "\(model.categoryId)" + ] + ) + + /// 카카오톡 메세지의 앱 이동 버튼 + let button = Button( + title: "앱에서 보기", + link: appLink + ) + + /// 카카오톡 메세지 내용 + let content = Content( + title: "\(model.categoryName) 포킷을 공유받았어요!", + imageUrl: URL(string: model.imageURL), + description: "소중한 링크들이 담긴 포킷을 Pokit 앱에서 지금 바로 확인해보세요!", + link: appLink + ) + + /// 피드 템플릿 + let template = FeedTemplate( + content: content, + buttons: [button] + ) + + guard ShareApi.isKakaoTalkSharingAvailable(), + let templateJsonData = try? SdkJSONEncoder.custom.encode(template), + let templateJsonObject = SdkUtils.toJsonObject(templateJsonData) else { + /// 🚨 Error Case [1]: 카카오톡 미설치 + guard let url = URL(string: "itms-apps://itunes.apple.com/app/id362057947"), + UIApplication.shared.canOpenURL(url) else { + return + } + + UIApplication.shared.open(url, options: [:], completionHandler: nil) + return + } + + let serverCallbackArgs = ["categoryId": "\(model.categoryId)"] + + ShareApi.shared.shareDefault( + templateObject: templateJsonObject, + serverCallbackArgs: serverCallbackArgs + ) { linkResult, error in + if let error = error { + print("error : \(error)") + } else { + print("defaultLink(templateObject:templateJsonObject) success.") + guard let linkResult else { return } + UIApplication.shared.open( + linkResult.url, + options: [:], + completionHandler: nil + ) + } + } + } + ) + } +} diff --git a/Projects/CoreKit/Sources/Data/Client/KakaoSDK/Share/KakaoShareClient.swift b/Projects/CoreKit/Sources/Data/Client/KakaoSDK/Share/KakaoShareClient.swift index d1adb5f8..27c47c98 100644 --- a/Projects/CoreKit/Sources/Data/Client/KakaoSDK/Share/KakaoShareClient.swift +++ b/Projects/CoreKit/Sources/Data/Client/KakaoSDK/Share/KakaoShareClient.swift @@ -7,91 +7,11 @@ import UIKit -import Dependencies -import KakaoSDKCommon -import KakaoSDKShare -import KakaoSDKTemplate +import DependenciesMacros +@DependencyClient public struct KakaoShareClient { - public var 카테고리_카카오톡_공유: ( - _ model: CategoryKaKaoShareModel - ) -> Void -} - -extension KakaoShareClient: DependencyKey { - public static var liveValue: KakaoShareClient { - return Self( - 카테고리_카카오톡_공유: { model in - /// 딥링크 - let appLink = Link( - androidExecutionParams: [ - "categoryId": "\(model.categoryId)" - ], iosExecutionParams: [ - "categoryId": "\(model.categoryId)" - ] - ) - - /// 카카오톡 메세지의 앱 이동 버튼 - let button = Button( - title: "앱에서 보기", - link: appLink - ) - - /// 카카오톡 메세지 내용 - let content = Content( - title: "\(model.categoryName) 포킷을 공유받았어요!", - imageUrl: URL(string: model.imageURL), - description: "소중한 링크들이 담긴 포킷을 Pokit 앱에서 지금 바로 확인해보세요!", - link: appLink - ) - - /// 피드 템플릿 - let template = FeedTemplate( - content: content, - buttons: [button] - ) - - guard ShareApi.isKakaoTalkSharingAvailable(), - let templateJsonData = try? SdkJSONEncoder.custom.encode(template), - let templateJsonObject = SdkUtils.toJsonObject(templateJsonData) else { - /// 🚨 Error Case [1]: 카카오톡 미설치 - guard let url = URL(string: "itms-apps://itunes.apple.com/app/id362057947"), - UIApplication.shared.canOpenURL(url) else { - return - } - - UIApplication.shared.open(url, options: [:], completionHandler: nil) - return - } - - let serverCallbackArgs = ["categoryId": "\(model.categoryId)"] - - ShareApi.shared.shareDefault( - templateObject: templateJsonObject, - serverCallbackArgs: serverCallbackArgs - ) { linkResult, error in - if let error = error { - print("error : \(error)") - } else { - print("defaultLink(templateObject:templateJsonObject) success.") - guard let linkResult else { return } - UIApplication.shared.open( - linkResult.url, - options: [:], - completionHandler: nil - ) - } - } - } - ) - } -} - -extension DependencyValues { - public var kakaoShareClient: KakaoShareClient { - get { self[KakaoShareClient.self] } - set { self[KakaoShareClient.self] = newValue } - } + public var 카테고리_카카오톡_공유: (_ model: CategoryKaKaoShareModel) -> Void } diff --git a/Projects/CoreKit/Sources/Data/Client/Keychain/KeychainClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/Keychain/KeychainClient+LiveKey.swift new file mode 100644 index 00000000..a447f9aa --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/Keychain/KeychainClient+LiveKey.swift @@ -0,0 +1,131 @@ +// +// KeychainClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies + +extension KeychainClient: DependencyKey { + public static let liveValue: Self = { + let controller = KeychainController() + return Self( + save: { + let data = $1.data(using: .utf8) + if let _ = controller.read($0) { + controller.update(data, key: $0) + return + } + controller.create(data, key: $0) + }, + read: { + guard let tokenData = controller.read($0) else { return nil } + return String(data: tokenData, encoding: .utf8) + }, + delete: { + controller.delete($0) + } + ) + }() +} +private struct KeychainController { + let service: String = "Pokit" + let group: String = "group.com.pokitmons.pokit" + + func create(_ data: Data?, key: KeychainKey) { + guard let data = data else { + print("🗝️ '\(key)' 값이 없어요.") + return + } + + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key.rawValue, + kSecValueData: data, + kSecAttrAccessGroup as String: group + ] + + let status = SecItemAdd(query, nil) + guard status == errSecSuccess else { + print("🗝️ '\(key)' 상태 = \(status)") + return + } + print("🗝️ '\(key)' 성공!") + } + + // MARK: Read Item + + func read(_ key: KeychainKey) -> Data? { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key.rawValue, + kSecMatchLimit: kSecMatchLimitOne, + kSecReturnData: true, + kSecAttrAccessGroup as String: group + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query, &result) + guard status != errSecItemNotFound else { + print("🗝️ '\(key)' 항목을 찾을 수 없어요.") + return nil + } + guard status == errSecSuccess else { + return nil + } + print("🗝️ '\(key)' 성공!") + return result as? Data + } + + // MARK: Update Item + + func update(_ data: Data?, key: KeychainKey) { + guard let data = data else { + print("🗝️ '\(key)' 값이 없어요.") + return + } + + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key.rawValue, + kSecAttrAccessGroup as String: group + ] + let attributes: NSDictionary = [ + kSecValueData: data + ] + + let status = SecItemUpdate(query, attributes) + guard status == errSecSuccess else { + print("🗝️ '\(key)' 상태 = \(status)") + return + } + print("🗝️ '\(key)' 성공!") + } + + // MARK: Delete Item + + func delete(_ key: KeychainKey) { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key.rawValue, + kSecAttrAccessGroup as String: group + ] + + let status = SecItemDelete(query) + guard status != errSecItemNotFound else { + print("🗝️ '\(key)' 항목을 찾을 수 없어요.") + return + } + guard status == errSecSuccess else { + return + } + print("🗝️ '\(key)' 성공!") + } +} diff --git a/Projects/CoreKit/Sources/Data/Client/Keychain/KeychainClient.swift b/Projects/CoreKit/Sources/Data/Client/Keychain/KeychainClient.swift index 85a2b72f..7688f463 100644 --- a/Projects/CoreKit/Sources/Data/Client/Keychain/KeychainClient.swift +++ b/Projects/CoreKit/Sources/Data/Client/Keychain/KeychainClient.swift @@ -7,7 +7,7 @@ import Foundation -import Dependencies +import DependenciesMacros public enum KeychainKey: String { case accessToken @@ -15,134 +15,10 @@ public enum KeychainKey: String { case serverRefresh } -// MARK: - Dependency Values -extension DependencyValues { - public var keychain: KeychainClient { - get { self[KeychainClient.self] } - set { self[KeychainClient.self] = newValue } - } -} - -// MARK: - KeychainClient Client +@DependencyClient public struct KeychainClient { public var save: @Sendable (KeychainKey, String) -> Void - public var read: @Sendable (KeychainKey) -> (String?) + public var read: @Sendable (KeychainKey) -> (String?) = { _ in nil } public var delete: @Sendable (KeychainKey) -> Void } -extension KeychainClient: DependencyKey { - public static let liveValue: Self = { - let controller = KeychainController() - return Self( - save: { - let data = $1.data(using: .utf8) - if let _ = controller.read($0) { - controller.update(data, key: $0) - return - } - controller.create(data, key: $0) - }, - read: { - guard let tokenData = controller.read($0) else { return nil } - return String(data: tokenData, encoding: .utf8) - }, - delete: { - controller.delete($0) - } - ) - }() -} - -private struct KeychainController { - let service: String = "Pokit" - - func create(_ data: Data?, key: KeychainKey) { - guard let data = data else { - print("🗝️ '\(key)' 값이 없어요.") - return - } - - let query: NSDictionary = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: key.rawValue, - kSecValueData: data - ] - - let status = SecItemAdd(query, nil) - guard status == errSecSuccess else { - print("🗝️ '\(key)' 상태 = \(status)") - return - } - print("🗝️ '\(key)' 성공!") - } - - // MARK: Read Item - - func read(_ key: KeychainKey) -> Data? { - let query: NSDictionary = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: key.rawValue, - kSecMatchLimit: kSecMatchLimitOne, - kSecReturnData: true - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query, &result) - guard status != errSecItemNotFound else { - print("🗝️ '\(key)' 항목을 찾을 수 없어요.") - return nil - } - guard status == errSecSuccess else { - return nil - } - print("🗝️ '\(key)' 성공!") - return result as? Data - } - - // MARK: Update Item - - func update(_ data: Data?, key: KeychainKey) { - guard let data = data else { - print("🗝️ '\(key)' 값이 없어요.") - return - } - - let query: NSDictionary = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: key.rawValue - ] - let attributes: NSDictionary = [ - kSecValueData: data - ] - - let status = SecItemUpdate(query, attributes) - guard status == errSecSuccess else { - print("🗝️ '\(key)' 상태 = \(status)") - return - } - print("🗝️ '\(key)' 성공!") - } - - // MARK: Delete Item - - func delete(_ key: KeychainKey) { - let query: NSDictionary = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: key.rawValue - ] - - let status = SecItemDelete(query) - guard status != errSecItemNotFound else { - print("🗝️ '\(key)' 항목을 찾을 수 없어요.") - return - } - guard status == errSecSuccess else { - return - } - print("🗝️ '\(key)' 성공!") - } -} diff --git a/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient+LiveKey.swift index b3598b56..7c8dc941 100644 --- a/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient+LiveKey.swift @@ -1,6 +1,6 @@ // -// Live.swift -// App +// PasteboardClient+LiveKey.swift +// CoreKit // // Created by 김민호 on 8/2/24. // @@ -13,48 +13,38 @@ import Dependencies extension PasteboardClient: DependencyKey { public static let liveValue: Self = { .init( - changes: { UIPasteboard.general.changes }, - probableWebURL: { () -> URL? in - do { - let pasteboard = UIPasteboard.general - - guard pasteboard.hasURLs else { return nil } - - let detectedPatterns = try await pasteboard - .detectedPatterns(for: [\.probableWebURL]) - - guard detectedPatterns.contains(\.probableWebURL) else { return nil } - - return pasteboard.url - } catch { throw error } - } + changes: { UIPasteboard.general.changes }, + probableWebURL: { () -> URL? in + do { + let pasteboard = UIPasteboard.general + + guard pasteboard.hasURLs else { return nil } + + let detectedPatterns = try await pasteboard + .detectedPatterns(for: [\.probableWebURL]) + + guard detectedPatterns.contains(\.probableWebURL) else { return nil } + + return pasteboard.url + } catch { throw error } + } ) - }() - - public static let previewValue = Self.noop -} - -extension PasteboardClient { - public static let noop = Self( - changes: { .finished }, - probableWebURL: { nil } - - ) + }() } extension UIPasteboard { - var changes: AsyncStream { - Publishers.Merge( - NotificationCenter.default - .publisher(for: UIPasteboard.changedNotification) - .compactMap { [weak self] _ in self?.changeCount }, - NotificationCenter.default - .publisher(for: UIApplication.didBecomeActiveNotification) - .compactMap { [weak self] _ in self?.changeCount } - ) - .removeDuplicates() - .map { _ in } - .values - .eraseToStream() - } + var changes: AsyncStream { + Publishers.Merge( + NotificationCenter.default + .publisher(for: UIPasteboard.changedNotification) + .compactMap { [weak self] _ in self?.changeCount }, + NotificationCenter.default + .publisher(for: UIApplication.didBecomeActiveNotification) + .compactMap { [weak self] _ in self?.changeCount } + ) + .removeDuplicates() + .map { _ in } + .values + .eraseToStream() + } } diff --git a/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient+TestKey.swift new file mode 100644 index 00000000..8ab35a87 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient+TestKey.swift @@ -0,0 +1,22 @@ +// +// PasteboardClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies + +extension PasteboardClient: TestDependencyKey { + public static let previewValue = Self.noop +} + +extension PasteboardClient { + public static let noop = Self( + changes: { .finished }, + probableWebURL: { nil } + + ) +} diff --git a/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient.swift b/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient.swift index 936a48c9..e6d7790c 100644 --- a/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient.swift +++ b/Projects/CoreKit/Sources/Data/Client/PasteboardClient/PasteboardClient.swift @@ -1,21 +1,15 @@ // -// Interface.swift -// App +// PasteboardClient.swift +// CoreKit // // Created by 김민호 on 8/2/24. // import Foundation -import Dependencies - -extension DependencyValues { - public var pasteboard: PasteboardClient { - get { self[PasteboardClient.self] } - set { self[PasteboardClient.self] = newValue } - } -} +import DependenciesMacros +@DependencyClient public struct PasteboardClient: Sendable { - public var changes: @Sendable () -> AsyncStream - public var probableWebURL: @Sendable () async throws -> URL? + public var changes: @Sendable () -> AsyncStream = { .finished } + public var probableWebURL: @Sendable () async throws -> URL? } diff --git a/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient+LiveKey.swift index a57391fc..47035e79 100644 --- a/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient+LiveKey.swift @@ -5,14 +5,14 @@ // Created by 김민호 on 7/5/24. // -//import Dependencies -//import UIKit -// -//@available(iOSApplicationExtension, unavailable) -//extension RemoteNotificationsClient: DependencyKey { -// public static let liveValue = Self( -// isRegistered: { await UIApplication.shared.isRegisteredForRemoteNotifications }, -// register: { await UIApplication.shared.registerForRemoteNotifications() }, -// unregister: { await UIApplication.shared.unregisterForRemoteNotifications() } -// ) -//} +import UIKit + +import Dependencies + +extension RemoteNotificationsClient: DependencyKey { + public static let liveValue = Self( + isRegistered: { await UIApplication.shared.isRegisteredForRemoteNotifications }, + register: { await UIApplication.shared.registerForRemoteNotifications() }, + unregister: { await UIApplication.shared.unregisterForRemoteNotifications() } + ) +} diff --git a/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient+TestKey.swift index 3a86b3ff..7b234733 100644 --- a/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient+TestKey.swift +++ b/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient+TestKey.swift @@ -8,31 +8,15 @@ import UIKit import Dependencies -extension DependencyValues { - public var remoteNotifications: RemoteNotificationsClient { - get { self[RemoteNotificationsClient.self] } - set { self[RemoteNotificationsClient.self] = newValue } - } -} - -@available(iOSApplicationExtension, unavailable) -extension RemoteNotificationsClient: DependencyKey { - public static let liveValue = Self( - isRegistered: { await UIApplication.shared.isRegisteredForRemoteNotifications }, - register: { await UIApplication.shared.registerForRemoteNotifications() }, - unregister: { await UIApplication.shared.unregisterForRemoteNotifications() } - ) -} - extension RemoteNotificationsClient: TestDependencyKey { - public static let previewValue = Self.noop - public static let testValue = Self() + public static let previewValue = Self.noop + public static let testValue = Self() } extension RemoteNotificationsClient { - public static let noop = Self( - isRegistered: { true }, - register: {}, - unregister: {} - ) + public static let noop = Self( + isRegistered: { true }, + register: {}, + unregister: {} + ) } diff --git a/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient.swift b/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient.swift index 49929ea1..27196f00 100644 --- a/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient.swift +++ b/Projects/CoreKit/Sources/Data/Client/RemoteNotificationClient/RemoteNotificationsClient.swift @@ -9,7 +9,7 @@ import DependenciesMacros @DependencyClient public struct RemoteNotificationsClient { - public var isRegistered: @Sendable () async -> Bool = { false } - public var register: @Sendable () async -> Void - public var unregister: @Sendable () async -> Void + public var isRegistered: @Sendable () async -> Bool = { false } + public var register: @Sendable () async -> Void + public var unregister: @Sendable () async -> Void } diff --git a/Projects/CoreKit/Sources/Data/Client/SocialLogin/Controller/GoogleLoginController.swift b/Projects/CoreKit/Sources/Data/Client/SocialLogin/Controller/GoogleLoginController.swift index 90ed61df..cea8809b 100644 --- a/Projects/CoreKit/Sources/Data/Client/SocialLogin/Controller/GoogleLoginController.swift +++ b/Projects/CoreKit/Sources/Data/Client/SocialLogin/Controller/GoogleLoginController.swift @@ -10,23 +10,29 @@ import GoogleSignIn public final class GoogleLoginController { private var continuation: CheckedContinuation? + private var root: UIViewController? @MainActor public func login() async throws -> SocialLoginInfo { - return try await withCheckedThrowingContinuation { continuation in self.continuation = continuation googleSignIn() } } + public func setRootViewController(_ root: UIViewController?) { + self.root = root + } + private func googleSignIn() { - guard let root = UIApplication.shared.rootViewController else { + let rootViewController = root ?? UIApplication.shared.rootViewController + + guard let rootViewController else { continuation?.resume(throwing: SocialLoginError.transferError("Root view does not exist.")) return } - GIDSignIn.sharedInstance.signIn(withPresenting: root) { [weak self] signInResult, error in + GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { [weak self] signInResult, error in guard let self else { return } if let error { continuation?.resume(throwing: error) diff --git a/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient+LiveKey.swift new file mode 100644 index 00000000..8b2e4c3c --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient+LiveKey.swift @@ -0,0 +1,30 @@ +// +// SocialLoginClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Dependencies + +extension SocialLoginClient: DependencyKey { + public static let liveValue: Self = { + let appleLoginController = AppleLoginController() + let googleLoginController = GoogleLoginController() + + return Self( + appleLogin: { + try await appleLoginController.login() + }, + googleLogin: { + try await googleLoginController.login() + }, + getClientSceret: { + return appleLoginController.makeJWT() + }, + setRootViewController: { rootViewController in + return googleLoginController.setRootViewController(rootViewController) + } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient+TestKey.swift new file mode 100644 index 00000000..1a116c61 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient+TestKey.swift @@ -0,0 +1,22 @@ +// +// SocialLoginClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies + +extension SocialLoginClient: TestDependencyKey { + public static let testValue = Self() + public static let previewValue: Self = { + Self( + appleLogin: { .appleMock }, + googleLogin: { .googleMock }, + getClientSceret: { "" }, + setRootViewController: { _ in } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient.swift b/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient.swift index 3663de96..b6bee519 100644 --- a/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient.swift +++ b/Projects/CoreKit/Sources/Data/Client/SocialLogin/SocialLoginClient.swift @@ -6,44 +6,13 @@ // import Foundation -import Dependencies +import DependenciesMacros +import UIKit +@DependencyClient public struct SocialLoginClient { public var appleLogin: @Sendable () async throws -> SocialLoginInfo public var googleLogin: @Sendable () async throws -> SocialLoginInfo - public var getClientSceret: @Sendable () -> String -} - -extension SocialLoginClient: DependencyKey { - public static let liveValue: Self = { - let appleLoginController = AppleLoginController() - let googleLoginController = GoogleLoginController() - - return Self( - appleLogin: { - try await appleLoginController.login() - }, - googleLogin: { - try await googleLoginController.login() - }, - getClientSceret: { - return appleLoginController.makeJWT() - } - ) - }() - - public static let previewValue: Self = { - Self( - appleLogin: { .appleMock }, - googleLogin: { .googleMock }, - getClientSceret: { "" } - ) - }() -} - -extension DependencyValues { - public var socialLogin: SocialLoginClient { - get { self[SocialLoginClient.self] } - set { self[SocialLoginClient.self] = newValue } - } + public var getClientSceret: @Sendable () -> String = { "" } + public var setRootViewController: @Sendable (UIViewController?) -> Void } diff --git a/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient+LiveKey.swift new file mode 100644 index 00000000..f4649b8d --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient+LiveKey.swift @@ -0,0 +1,26 @@ +// +// SwiftSoupClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies +import SwiftSoup + +extension SwiftSoupClient: DependencyKey { + public static let liveValue: Self = { + let provider = SwiftSoupProvider() + + return Self( + 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+TestKey.swift b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient+TestKey.swift new file mode 100644 index 00000000..b940b95b --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient+TestKey.swift @@ -0,0 +1,14 @@ +// +// SwiftSoupClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies + +extension SwiftSoupClient: TestDependencyKey { + public static let testValue = Self() +} diff --git a/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient.swift b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient.swift index 60e70d18..6f2a18ab 100644 --- a/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient.swift +++ b/Projects/CoreKit/Sources/Data/Client/SwiftSoupClient/SwiftSoupClient.swift @@ -7,63 +7,15 @@ import Foundation -import SwiftSoup -import Dependencies +import DependenciesMacros +@DependencyClient public struct SwiftSoupClient { - public var parseOGTitleAndImage: @Sendable ( - _ url: URL, - _ completion: @Sendable () async -> Void - ) async -> (String?, String?) -} - -extension SwiftSoupClient: DependencyKey { - public static let liveValue: Self = { - 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) - } - ) - }() + public var parseOGTitle: @Sendable ( + _ url: URL + ) async throws -> String? = { _ in nil } - public static let previewValue: Self = liveValue -} - -public extension DependencyValues { - var swiftSoup: SwiftSoupClient { - get { self[SwiftSoupClient.self] } - set { self[SwiftSoupClient.self] = newValue } - } + 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/CoreKit/Sources/Data/Client/UserDefaults/UserDefaults.swift b/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaults.swift deleted file mode 100644 index 46fa0066..00000000 --- a/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaults.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// UserDefaults.swift -// Data -// -// Created by 김민호 on 7/30/24. -// - -import Foundation - -import Dependencies - -extension DependencyValues { - public var userDefaults: UserDefaultsClient { - get { self[UserDefaultsClient.self] } - set { self[UserDefaultsClient.self] = newValue } - } -} - -public struct UserDefaultsClient { - public var boolKey: @Sendable (UserDefaultsKey.BoolKey) -> Bool = { _ in false } - public var stringKey: @Sendable (UserDefaultsKey.StringKey) -> String? = { _ in "" } - public var stringArrayKey: @Sendable (UserDefaultsKey.ArrayKey) -> [String]? = { _ in [] } - public var removeBool: @Sendable (UserDefaultsKey.BoolKey) async -> Void - public var removeString: @Sendable (UserDefaultsKey.StringKey) async -> Void - public var removeStringArray: @Sendable (UserDefaultsKey.ArrayKey) async -> Void - public var setBool: @Sendable (Bool, UserDefaultsKey.BoolKey) async -> Void - public var setString: @Sendable (String, UserDefaultsKey.StringKey) async -> Void - public var setStringArray: @Sendable ([String], UserDefaultsKey.ArrayKey) async -> Void -} - -extension UserDefaultsClient: DependencyKey { - public static var liveValue: Self = { - let defaults = { UserDefaults.standard } - - return Self( - boolKey: { defaults().bool(forKey: $0.rawValue) }, - stringKey: { defaults().string(forKey: $0.rawValue) }, - stringArrayKey: { defaults().stringArray(forKey: $0.rawValue) }, - - removeBool: { defaults().removeObject(forKey: $0.rawValue) }, - removeString: { defaults().removeObject(forKey: $0.rawValue) }, - removeStringArray: { defaults().removeObject(forKey: $0.rawValue) }, - - setBool: { defaults().set($0, forKey: $1.rawValue) }, - setString: { defaults().set($0, forKey: $1.rawValue) }, - setStringArray: { defaults().set($0, forKey: $1.rawValue) } - ) - }() - - public static let testValue: Self = { - Self( - removeBool: { _ in }, - removeString: { _ in }, - removeStringArray: { _ in }, - setBool: { _, _ in }, - setString: { _, _ in }, - setStringArray: {_, _ in } - ) - }() -} diff --git a/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsClient+LiveKey.swift new file mode 100644 index 00000000..e2d01633 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsClient+LiveKey.swift @@ -0,0 +1,30 @@ +// +// UserDefaultsClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies + +extension UserDefaultsClient: DependencyKey { + public static var liveValue: Self = { + let defaults = { UserDefaults(suiteName: "group.com.pokitmons.pokit") } + + return Self( + boolKey: { defaults()?.bool(forKey: $0.rawValue) ?? false }, + stringKey: { defaults()?.string(forKey: $0.rawValue) }, + stringArrayKey: { defaults()?.stringArray(forKey: $0.rawValue) }, + + removeBool: { defaults()?.removeObject(forKey: $0.rawValue) }, + removeString: { defaults()?.removeObject(forKey: $0.rawValue) }, + removeStringArray: { defaults()?.removeObject(forKey: $0.rawValue) }, + + setBool: { defaults()?.set($0, forKey: $1.rawValue) }, + setString: { defaults()?.set($0, forKey: $1.rawValue) }, + setStringArray: { defaults()?.set($0, forKey: $1.rawValue) } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsClient+TestKey.swift new file mode 100644 index 00000000..bf168731 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsClient+TestKey.swift @@ -0,0 +1,14 @@ +// +// UserDefaultsClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies + +extension UserDefaultsClient: TestDependencyKey { + public static var testValue = Self() +} diff --git a/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsClient.swift b/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsClient.swift new file mode 100644 index 00000000..951d766e --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsClient.swift @@ -0,0 +1,23 @@ +// +// UserDefaultsClient.swift +// CoreKit +// +// Created by 김민호 on 7/30/24. +// + +import Foundation + +import DependenciesMacros + +@DependencyClient +public struct UserDefaultsClient { + public var boolKey: @Sendable (UserDefaultsKey.BoolKey) -> Bool = { _ in false } + public var stringKey: @Sendable (UserDefaultsKey.StringKey) -> String? = { _ in "" } + public var stringArrayKey: @Sendable (UserDefaultsKey.ArrayKey) -> [String]? = { _ in [] } + public var removeBool: @Sendable (UserDefaultsKey.BoolKey) async -> Void + public var removeString: @Sendable (UserDefaultsKey.StringKey) async -> Void + public var removeStringArray: @Sendable (UserDefaultsKey.ArrayKey) async -> Void + public var setBool: @Sendable (Bool, UserDefaultsKey.BoolKey) async -> Void + public var setString: @Sendable (String, UserDefaultsKey.StringKey) async -> Void + public var setStringArray: @Sendable ([String], UserDefaultsKey.ArrayKey) async -> Void +} diff --git a/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsKey.swift b/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsKey.swift index 51360900..3a894784 100644 --- a/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsKey.swift +++ b/Projects/CoreKit/Sources/Data/Client/UserDefaults/UserDefaultsKey.swift @@ -1,6 +1,6 @@ // // UserDefaultsKey.swift -// Data +// CoreKit // // Created by 김민호 on 7/30/24. // diff --git a/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+LiveKey.swift new file mode 100644 index 00000000..e249da8a --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+LiveKey.swift @@ -0,0 +1,99 @@ +// +// UserNotificationClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import UserNotifications +import Dependencies + +extension UserNotificationClient: DependencyKey { + public static let liveValue = Self( + add: { try await UNUserNotificationCenter.current().add($0) }, + delegate: { + AsyncStream { continuation in + let delegate = Delegate(continuation: continuation) + UNUserNotificationCenter.current().delegate = delegate + continuation.onTermination = { _ in + _ = delegate + } + } + }, + getNotificationSettings: { + await Notification.Settings( + rawValue: UNUserNotificationCenter.current().notificationSettings() + ) + }, + removeDeliveredNotificationsWithIdentifiers: { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: $0) + }, + removePendingNotificationRequestsWithIdentifiers: { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: $0) + }, + requestAuthorization: { + try await UNUserNotificationCenter.current().requestAuthorization(options: $0) + } + ) +} + +extension UserNotificationClient.Notification { + public init(rawValue: UNNotification) { + self.date = rawValue.date + self.request = rawValue.request + } +} + +extension UserNotificationClient.Notification.Response { + public init(rawValue: UNNotificationResponse) { + self.notification = .init(rawValue: rawValue.notification) + } +} + +extension UserNotificationClient.Notification.Settings { + public init(rawValue: UNNotificationSettings) { + self.authorizationStatus = rawValue.authorizationStatus + } +} + +extension UserNotificationClient { + fileprivate class Delegate: NSObject, UNUserNotificationCenterDelegate { + let continuation: AsyncStream.Continuation + + init(continuation: AsyncStream.Continuation) { + self.continuation = continuation + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + self.continuation.yield( + .didReceiveResponse(.init(rawValue: response)) { completionHandler() } + ) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + openSettingsFor notification: UNNotification? + ) { + self.continuation.yield( + .openSettingsForNotification(notification.map(Notification.init(rawValue:))) + ) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: + @escaping (UNNotificationPresentationOptions) -> Void + ) { + self.continuation.yield( + .willPresentNotification(.init(rawValue: notification)) { completionHandler($0) } + ) + } + } +} diff --git a/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+TestKey.swift index ec844eaf..ea7b74c1 100644 --- a/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+TestKey.swift +++ b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient+TestKey.swift @@ -6,15 +6,10 @@ // import Dependencies -//import Combine -import ComposableArchitecture -import UserNotifications -extension DependencyValues { - public var userNotifications: UserNotificationClient { - get { self[UserNotificationClient.self] } - set { self[UserNotificationClient.self] = newValue } - } +extension UserNotificationClient: TestDependencyKey { + public static let previewValue = Self.noop + public static let testValue = Self() } extension UserNotificationClient { @@ -28,95 +23,4 @@ extension UserNotificationClient { ) } -extension UserNotificationClient: DependencyKey { - public static let liveValue = Self( - add: { try await UNUserNotificationCenter.current().add($0) }, - delegate: { - AsyncStream { continuation in - let delegate = Delegate(continuation: continuation) - UNUserNotificationCenter.current().delegate = delegate - continuation.onTermination = { _ in - _ = delegate - } - } - }, - getNotificationSettings: { - await Notification.Settings( - rawValue: UNUserNotificationCenter.current().notificationSettings() - ) - }, - removeDeliveredNotificationsWithIdentifiers: { - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: $0) - }, - removePendingNotificationRequestsWithIdentifiers: { - UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: $0) - }, - requestAuthorization: { - try await UNUserNotificationCenter.current().requestAuthorization(options: $0) - } - ) -} - -extension UserNotificationClient: TestDependencyKey { - public static let previewValue = Self.noop - public static let testValue = Self() -} - -extension UserNotificationClient.Notification { - public init(rawValue: UNNotification) { - self.date = rawValue.date - self.request = rawValue.request - } -} - -extension UserNotificationClient.Notification.Response { - public init(rawValue: UNNotificationResponse) { - self.notification = .init(rawValue: rawValue.notification) - } -} -extension UserNotificationClient.Notification.Settings { - public init(rawValue: UNNotificationSettings) { - self.authorizationStatus = rawValue.authorizationStatus - } -} - -extension UserNotificationClient { - fileprivate class Delegate: NSObject, UNUserNotificationCenterDelegate { - let continuation: AsyncStream.Continuation - - init(continuation: AsyncStream.Continuation) { - self.continuation = continuation - } - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - self.continuation.yield( - .didReceiveResponse(.init(rawValue: response)) { completionHandler() } - ) - } - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - openSettingsFor notification: UNNotification? - ) { - self.continuation.yield( - .openSettingsForNotification(notification.map(Notification.init(rawValue:))) - ) - } - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: - @escaping (UNNotificationPresentationOptions) -> Void - ) { - self.continuation.yield( - .willPresentNotification(.init(rawValue: notification)) { completionHandler($0) } - ) - } - } -} diff --git a/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient.swift b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient.swift index 971be1ea..64d8e411 100644 --- a/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient.swift +++ b/Projects/CoreKit/Sources/Data/Client/UserNotificationClient/UserNotificationClient.swift @@ -5,6 +5,7 @@ // Created by 김민호 on 7/4/24. // +import CasePaths import ComposableArchitecture import UserNotifications diff --git a/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient+LiveKey.swift new file mode 100644 index 00000000..a1b723d4 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient+LiveKey.swift @@ -0,0 +1,26 @@ +// +// AlertClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies +import Moya + +extension AlertClient: DependencyKey { + public static let liveValue: Self = { + let provider = MoyaProvider.build() + + return Self( + 알람_목록_조회: { model in + try await provider.request(.알람_목록_조회(model: model)) + }, + 알람_삭제: { alertId in + try await provider.requestNoBody(.알람_삭제(alertId: alertId)) + } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient+TestKey.swift new file mode 100644 index 00000000..30333db8 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient+TestKey.swift @@ -0,0 +1,20 @@ +// +// AlertClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies +import Moya + +extension AlertClient: TestDependencyKey { + public static let previewValue: Self = { + Self( + 알람_목록_조회: { _ in .mock }, + 알람_삭제: { _ in } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient.swift b/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient.swift index 31639ebf..3eb885f5 100644 --- a/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Alert/AlertClient.swift @@ -5,43 +5,12 @@ // Created by 김민호 on 8/17/24. // -import Foundation +import DependenciesMacros -import Dependencies -import Moya - -// MARK: - Dependency Values -extension DependencyValues { - public var alertClient: AlertClient { - get { self[AlertClient.self] } - set { self[AlertClient.self] = newValue } - } -} -/// 알람에 관련한 API를 처리하는 Client +@DependencyClient public struct AlertClient { public var 알람_목록_조회: @Sendable (BasePageableRequest) async throws -> AlertListInquiryResponse public var 알람_삭제: @Sendable (_ alertId: String) async throws -> Void } -extension AlertClient: DependencyKey { - public static let liveValue: Self = { - let provider = MoyaProvider.build() - - return Self( - 알람_목록_조회: { model in - try await provider.request(.알람_목록_조회(model: model)) - }, - 알람_삭제: { alertId in - try await provider.requestNoBody(.알람_삭제(alertId: alertId)) - } - ) - }() - - public static let previewValue: Self = { - Self( - 알람_목록_조회: { _ in .mock }, - 알람_삭제: { _ in } - ) - }() -} diff --git a/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient+LiveKey.swift new file mode 100644 index 00000000..36b79dae --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient+LiveKey.swift @@ -0,0 +1,34 @@ +// +// AuthClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Dependencies +import Moya + +extension AuthClient: DependencyKey { + public static let liveValue: Self = { + let nonTokenProvider = MoyaProvider.buildNonToken() + let provider = MoyaProvider.build() + + return Self( + 로그인: { model in + try await nonTokenProvider.request(.로그인(model)) + }, + 회원탈퇴: { model in + try await provider.requestNoBody(.회원탈퇴(model)) + }, + 토큰재발급: { model in + try await nonTokenProvider.request(.토큰재발급(model)) + }, + apple: { model in + try await nonTokenProvider.request(.apple(model)) + }, + appleRevoke: { refreshToken, model in + try await nonTokenProvider.requestNoBody(.appleRevoke(refreshToken, model)) + } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient+TestKey.swift new file mode 100644 index 00000000..a63b85a4 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient+TestKey.swift @@ -0,0 +1,22 @@ +// +// AuthClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies + +extension AuthClient: TestDependencyKey { + public static let previewValue: Self = { + Self( + 로그인: { _ in .mock }, + 회원탈퇴: { _ in }, + 토큰재발급: { _ in .mock }, + apple: { _ in .init(refresh_token: "") }, + appleRevoke: { _, _ in } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient.swift b/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient.swift index b0f40d8c..68b9bf55 100644 --- a/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Auth/AuthClient.swift @@ -5,19 +5,9 @@ // Created by 김민호 on 7/30/24. // -import Foundation +import DependenciesMacros -import Dependencies -import Moya - -// MARK: - Dependency Values -extension DependencyValues { - public var authClient: AuthClient { - get { self[AuthClient.self] } - set { self[AuthClient.self] = newValue } - } -} -/// 유저정보에 관련한 API를 처리하는 Client +@DependencyClient public struct AuthClient { public var 로그인: @Sendable (SignInRequest) async throws -> TokenResponse public var 회원탈퇴: @Sendable (WithdrawRequest) async throws -> Void @@ -26,38 +16,3 @@ public struct AuthClient { public var appleRevoke: @Sendable (String, AppleTokenRequest) async throws -> Void } -extension AuthClient: DependencyKey { - public static let liveValue: Self = { - /// 로그인 api (자동로그인 / 로그인)용 access token이 필요없을 때 사용하는 provider - let nonTokenProvider = MoyaProvider.buildNonToken() - let provider = MoyaProvider.build() - - return Self( - 로그인: { model in - try await nonTokenProvider.request(.로그인(model)) - }, - 회원탈퇴: { model in - try await provider.requestNoBody(.회원탈퇴(model)) - }, - 토큰재발급: { model in - try await nonTokenProvider.request(.토큰재발급(model)) - }, - apple: { model in - try await nonTokenProvider.request(.apple(model)) - }, - appleRevoke: { refreshToken, model in - try await nonTokenProvider.requestNoBody(.appleRevoke(refreshToken, model)) - } - ) - }() - - public static let previewValue: Self = { - Self( - 로그인: { _ in .mock }, - 회원탈퇴: { _ in }, - 토큰재발급: { _ in .mock }, - apple: { _ in .init(refresh_token: "") }, - appleRevoke: { _, _ in } - ) - }() -} diff --git a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift new file mode 100644 index 00000000..58bb09c2 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift @@ -0,0 +1,47 @@ +// +// CategoryClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies +import Moya + +extension CategoryClient: DependencyKey { + public static let liveValue: Self = { + let provider = MoyaProvider.build() + + return Self( + 카테고리_삭제: { id in + try await provider.requestNoBody(.카테고리_삭제(categoryId: id)) + }, + 카테고리_수정: { id, model in + try await provider.request(.카테고리_수정(categoryId: id, model: model)) + }, + 카테고리_목록_조회: { model, categoryFilter in + try await provider.request(.카테고리_목록_조회(model: model, filterUncategorized: categoryFilter)) + }, + 카테고리_생성: { model in + try await provider.request(.카테고리생성(model: model)) + }, + 카테고리_프로필_목록_조회: { + try await provider.request(.카테고리_프로필_목록_조회) + }, + 유저_카테고리_개수_조회: { + try await provider.request(.유저_카테고리_개수_조회) + }, + 카테고리_상세_조회: { id in + try await provider.request(.카테고리_상세_조회(categoryId: id)) + }, + 공유받은_카테고리_조회: { id, model in + try await provider.request(.공유받은_카테고리_조회(categoryId: id, model: model)) + }, + 공유받은_카테고리_저장: { model in + try await provider.requestNoBody(.공유받은_카테고리_저장(model: model)) + } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+TestKey.swift new file mode 100644 index 00000000..6798f1b1 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+TestKey.swift @@ -0,0 +1,24 @@ +// +// CategoryClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Dependencies + +extension CategoryClient: TestDependencyKey { + public static let previewValue: Self = { + Self( + 카테고리_삭제: { _ in }, + 카테고리_수정: { _, _ in .mock }, + 카테고리_목록_조회: { _, _ in .mock }, + 카테고리_생성: { _ in .mock }, + 카테고리_프로필_목록_조회: { CategoryImageResponse.mock }, + 유저_카테고리_개수_조회: { .mock }, + 카테고리_상세_조회: { _ in .mock }, + 공유받은_카테고리_조회: { _, _ in .mock }, + 공유받은_카테고리_저장: { _ in } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient.swift b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient.swift index ee31ac13..3bb41482 100644 --- a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient.swift @@ -5,18 +5,9 @@ // Created by 김민호 on 7/31/24. // -import Foundation +import DependenciesMacros -import Dependencies -import Moya - -extension DependencyValues { - public var categoryClient: CategoryClient { - get { self[CategoryClient.self] } - set { self[CategoryClient.self] = newValue } - } -} -/// Category에 관련한 API를 처리하는 Client +@DependencyClient public struct CategoryClient { public var 카테고리_삭제: @Sendable (_ categoryId: Int) async throws -> Void public var 카테고리_수정: @Sendable ( @@ -43,52 +34,3 @@ public struct CategoryClient { ) async throws -> Void } -extension CategoryClient: DependencyKey { - public static let liveValue: Self = { - let provider = MoyaProvider.build() - - return Self( - 카테고리_삭제: { id in - try await provider.requestNoBody(.카테고리_삭제(categoryId: id)) - }, - 카테고리_수정: { id, model in - try await provider.request(.카테고리_수정(categoryId: id, model: model)) - }, - 카테고리_목록_조회: { model, categoryFilter in - try await provider.request(.카테고리_목록_조회(model: model, filterUncategorized: categoryFilter)) - }, - 카테고리_생성: { model in - try await provider.request(.카테고리생성(model: model)) - }, - 카테고리_프로필_목록_조회: { - try await provider.request(.카테고리_프로필_목록_조회) - }, - 유저_카테고리_개수_조회: { - try await provider.request(.유저_카테고리_개수_조회) - }, - 카테고리_상세_조회: { id in - try await provider.request(.카테고리_상세_조회(categoryId: id)) - }, - 공유받은_카테고리_조회: { id, model in - try await provider.request(.공유받은_카테고리_조회(categoryId: id, model: model)) - }, - 공유받은_카테고리_저장: { model in - try await provider.requestNoBody(.공유받은_카테고리_저장(model: model)) - } - ) - }() - - public static let previewValue: Self = { - Self( - 카테고리_삭제: { _ in }, - 카테고리_수정: { _, _ in .mock }, - 카테고리_목록_조회: { _, _ in .mock }, - 카테고리_생성: { _ in .mock }, - 카테고리_프로필_목록_조회: { CategoryImageResponse.mock }, - 유저_카테고리_개수_조회: { .mock }, - 카테고리_상세_조회: { _ in .mock }, - 공유받은_카테고리_조회: { _, _ in .mock }, - 공유받은_카테고리_저장: { _ in } - ) - }() -} diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift new file mode 100644 index 00000000..d5027625 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift @@ -0,0 +1,56 @@ +// +// ContentClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Dependencies +import Moya + +extension ContentClient: DependencyKey { + public static let liveValue: Self = { + let provider = MoyaProvider.build() + + return Self( + 컨텐츠_삭제: { id in + try await provider.requestNoBody(.컨텐츠_삭제(contentId: id)) + }, + 컨텐츠_상세_조회: { id in + try await provider.request(.컨텐츠_상세_조회(contentId: id)) + }, + 컨텐츠_수정: { id, model in + try await provider.request(.컨텐츠_수정(contentId: id, model: model)) + }, + 컨텐츠_추가: { model in + try await provider.request(.컨텐츠_추가(model: model)) + }, + 즐겨찾기: { id in + try await provider.request(.즐겨찾기(contentId: id)) + }, + 즐겨찾기_취소: { id in + try await provider.requestNoBody(.즐겨찾기_취소(contentId: id)) + }, + 카테고리_내_컨텐츠_목록_조회: { id, pageable, condition in + try await provider.request( + .카태고리_내_컨텐츠_목록_조회( + contentId: id, + pageable: pageable, + condition: condition + ) + ) + }, + 미분류_카테고리_컨텐츠_조회: { model in + try await provider.request(.미분류_카테고리_컨텐츠_조회(model: model)) + }, + 컨텐츠_검색: { pageable, condition in + try await provider.request( + .컨텐츠_검색( + pageable: pageable, + condition: condition + ) + ) + } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift new file mode 100644 index 00000000..7b22cf86 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift @@ -0,0 +1,23 @@ +// +// ContentClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// +import Dependencies + +extension ContentClient: TestDependencyKey { + public static let previewValue: Self = { + Self( + 컨텐츠_삭제: { _ in }, + 컨텐츠_상세_조회: { _ in .mock }, + 컨텐츠_수정: { _, _ in .mock }, + 컨텐츠_추가: { _ in .mock }, + 즐겨찾기: { _ in .mock }, + 즐겨찾기_취소: { _ in }, + 카테고리_내_컨텐츠_목록_조회: { _, _, _ in .mock }, + 미분류_카테고리_컨텐츠_조회: { _ in .mock }, + 컨텐츠_검색: { _, _ in .mock } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift index 8c90e961..c7210f37 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift @@ -5,18 +5,9 @@ // Created by 김민호 on 7/31/24. // -import Foundation +import DependenciesMacros -import Dependencies -import Moya - -extension DependencyValues { - public var contentClient: ContentClient { - get { self[ContentClient.self] } - set { self[ContentClient.self] = newValue } - } -} -/// Category에 관련한 API를 처리하는 Client +@DependencyClient public struct ContentClient { public var 컨텐츠_삭제: @Sendable ( _ categoryId: String @@ -51,63 +42,3 @@ public struct ContentClient { ) async throws -> ContentListInquiryResponse } -extension ContentClient: DependencyKey { - public static let liveValue: Self = { - let provider = MoyaProvider.build() - - return Self( - 컨텐츠_삭제: { id in - try await provider.requestNoBody(.컨텐츠_삭제(contentId: id)) - }, - 컨텐츠_상세_조회: { id in - try await provider.request(.컨텐츠_상세_조회(contentId: id)) - }, - 컨텐츠_수정: { id, model in - try await provider.request(.컨텐츠_수정(contentId: id, model: model)) - }, - 컨텐츠_추가: { model in - try await provider.request(.컨텐츠_추가(model: model)) - }, - 즐겨찾기: { id in - try await provider.request(.즐겨찾기(contentId: id)) - }, - 즐겨찾기_취소: { id in - try await provider.requestNoBody(.즐겨찾기_취소(contentId: id)) - }, - 카테고리_내_컨텐츠_목록_조회: { id, pageable, condition in - try await provider.request( - .카태고리_내_컨텐츠_목록_조회( - contentId: id, - pageable: pageable, - condition: condition - ) - ) - }, - 미분류_카테고리_컨텐츠_조회: { model in - try await provider.request(.미분류_카테고리_컨텐츠_조회(model: model)) - }, - 컨텐츠_검색: { pageable, condition in - try await provider.request( - .컨텐츠_검색( - pageable: pageable, - condition: condition - ) - ) - } - ) - }() - - public static let previewValue: Self = { - Self( - 컨텐츠_삭제: { _ in }, - 컨텐츠_상세_조회: { _ in .mock }, - 컨텐츠_수정: { _, _ in .mock }, - 컨텐츠_추가: { _ in .mock }, - 즐겨찾기: { _ in .mock }, - 즐겨찾기_취소: { _ in }, - 카테고리_내_컨텐츠_목록_조회: { _, _, _ in .mock }, - 미분류_카테고리_컨텐츠_조회: { _ in .mock }, - 컨텐츠_검색: { _, _ in .mock } - ) - }() -} diff --git a/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient+LiveKey.swift new file mode 100644 index 00000000..cdf25659 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient+LiveKey.swift @@ -0,0 +1,35 @@ +// +// RemindClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies +import Moya + +extension RemindClient: DependencyKey { + public static let liveValue: Self = { + let provider = MoyaProvider.build() + + return .init( + 오늘의_리마인드_조회: { + try await provider.request(.오늘의_리마인드_조회) + }, + 읽지않음_컨텐츠_조회: { model in + try await provider.request(.읽지않음_컨텐츠_조회(model: model)) + }, + 즐겨찾기_링크모음_조회: { model in + try await provider.request(.즐겨찾기_링크모음_조회(model: model)) + }, + 읽지않음_컨텐츠_개수_조회: { + try await provider.request(.읽지않음_컨텐츠_개수_조회) + }, + 즐겨찾기_컨텐츠_개수_조회: { + try await provider.request(.즐겨찾기_컨텐츠_개수_조회) + } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient+TestKey.swift new file mode 100644 index 00000000..a5045376 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient+TestKey.swift @@ -0,0 +1,20 @@ +// +// RemindClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Dependencies + +extension RemindClient: TestDependencyKey { + public static let previewValue: Self = { + .init( + 오늘의_리마인드_조회: { [.mock(id: 0), .mock(id: 1), .mock(id: 2)]}, + 읽지않음_컨텐츠_조회: { _ in .mock }, + 즐겨찾기_링크모음_조회: { _ in .mock }, + 읽지않음_컨텐츠_개수_조회: { .mock }, + 즐겨찾기_컨텐츠_개수_조회: { .mock } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient.swift b/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient.swift index ba3ea10f..b7ee3c8c 100644 --- a/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Remind/RemindClient.swift @@ -5,11 +5,9 @@ // Created by 김도형 on 8/8/24. // -import Foundation - -import Dependencies -import Moya +import DependenciesMacros +@DependencyClient public struct RemindClient { public var 오늘의_리마인드_조회: @Sendable () async throws -> [ContentBaseResponse] @@ -23,46 +21,4 @@ public struct RemindClient { async throws -> UnreadCountResponse public var 즐겨찾기_컨텐츠_개수_조회: @Sendable () async throws -> BookmarkCountResponse - -} - -extension RemindClient: DependencyKey { - public static let liveValue: Self = { - let provider = MoyaProvider.build() - - return .init( - 오늘의_리마인드_조회: { - try await provider.request(.오늘의_리마인드_조회) - }, - 읽지않음_컨텐츠_조회: { model in - try await provider.request(.읽지않음_컨텐츠_조회(model: model)) - }, - 즐겨찾기_링크모음_조회: { model in - try await provider.request(.즐겨찾기_링크모음_조회(model: model)) - }, - 읽지않음_컨텐츠_개수_조회: { - try await provider.request(.읽지않음_컨텐츠_개수_조회) - }, - 즐겨찾기_컨텐츠_개수_조회: { - try await provider.request(.즐겨찾기_컨텐츠_개수_조회) - } - ) - }() - - public static let previewValue: Self = { - .init( - 오늘의_리마인드_조회: { [.mock(id: 0), .mock(id: 1), .mock(id: 2)]}, - 읽지않음_컨텐츠_조회: { _ in .mock }, - 즐겨찾기_링크모음_조회: { _ in .mock }, - 읽지않음_컨텐츠_개수_조회: { .mock }, - 즐겨찾기_컨텐츠_개수_조회: { .mock } - ) - }() -} - -extension DependencyValues { - public var remindClient: RemindClient { - get { self[RemindClient.self] } - set { self[RemindClient.self] = newValue } - } } diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift new file mode 100644 index 00000000..a268c605 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift @@ -0,0 +1,38 @@ +// +// UserClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies +import Moya + +extension UserClient: DependencyKey { + public static let liveValue: Self = { + let provider = MoyaProvider.build() + + return Self( + 닉네임_수정: { model in + try await provider.request(.닉네임_수정(model: model)) + }, + 회원등록: { model in + try await provider.request(.회원등록(model: model)) + }, + 닉네임_중복_체크: { nickname in + try await provider.request(.닉네임_중복_체크(nickname: nickname)) + }, + 관심사_목록_조회: { + try await provider.request(.관심사_목록_조회) + }, + 닉네임_조회: { + try await provider.request(.닉네임_조회) + }, + fcm_토큰_저장: { model in + try await provider.request(.fcm_토큰_저장(model: model)) + } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift new file mode 100644 index 00000000..2f3d37c8 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift @@ -0,0 +1,23 @@ +// +// UserClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Foundation + +import Dependencies + +extension UserClient: TestDependencyKey { + public static let previewValue: Self = { + Self( + 닉네임_수정: { _ in .mock }, + 회원등록: { _ in .mock }, + 닉네임_중복_체크: { _ in .mock }, + 관심사_목록_조회: { InterestResponse.mock }, + 닉네임_조회: { .mock }, + fcm_토큰_저장: { _ in .mock } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift index 64fab6cc..7ccb2149 100644 --- a/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift @@ -5,18 +5,9 @@ // Created by 김민호 on 7/31/24. // -import Foundation +import DependenciesMacros -import Dependencies -import Moya - -extension DependencyValues { - public var userClient: UserClient { - get { self[UserClient.self] } - set { self[UserClient.self] = newValue } - } -} -/// Category에 관련한 API를 처리하는 Client +@DependencyClient public struct UserClient { public var 닉네임_수정: @Sendable (_ model: NicknameEditRequest) async throws -> BaseUserResponse public var 회원등록: @Sendable (_ model: SignupRequest) async throws -> BaseUserResponse @@ -25,41 +16,3 @@ public struct UserClient { public var 닉네임_조회: @Sendable () async throws -> BaseUserResponse public var fcm_토큰_저장: @Sendable (_ model: FCMRequest) async throws -> FCMResponse } - -extension UserClient: DependencyKey { - public static let liveValue: Self = { - let provider = MoyaProvider.build() - - return Self( - 닉네임_수정: { model in - try await provider.request(.닉네임_수정(model: model)) - }, - 회원등록: { model in - try await provider.request(.회원등록(model: model)) - }, - 닉네임_중복_체크: { nickname in - try await provider.request(.닉네임_중복_체크(nickname: nickname)) - }, - 관심사_목록_조회: { - try await provider.request(.관심사_목록_조회) - }, - 닉네임_조회: { - try await provider.request(.닉네임_조회) - }, - fcm_토큰_저장: { model in - try await provider.request(.fcm_토큰_저장(model: model)) - } - ) - }() - - public static let previewValue: Self = { - Self( - 닉네임_수정: { _ in .mock }, - 회원등록: { _ in .mock }, - 닉네임_중복_체크: { _ in .mock }, - 관심사_목록_조회: { InterestResponse.mock }, - 닉네임_조회: { .mock }, - fcm_토큰_저장: { _ in .mock } - ) - }() -} diff --git a/Projects/CoreKit/Sources/Data/Network/Version/VersionClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Version/VersionClient+LiveKey.swift new file mode 100644 index 00000000..f0010c4e --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Version/VersionClient+LiveKey.swift @@ -0,0 +1,21 @@ +// +// VersionClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Dependencies +import Moya + +extension VersionClient: DependencyKey { + public static let liveValue: Self = { + let nonProvider = MoyaProvider.buildNonToken() + + return Self( + 버전체크: { + try await nonProvider.request(.버전체크) + } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Version/VersionClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Version/VersionClient+TestKey.swift new file mode 100644 index 00000000..f470384b --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Network/Version/VersionClient+TestKey.swift @@ -0,0 +1,16 @@ +// +// VersionClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 9/30/24. +// + +import Dependencies + +extension VersionClient: TestDependencyKey { + public static let previewValue: Self = { + Self( + 버전체크: { .mock } + ) + }() +} diff --git a/Projects/CoreKit/Sources/Data/Network/Version/VersionClient.swift b/Projects/CoreKit/Sources/Data/Network/Version/VersionClient.swift index ba97bf3b..ef3d6d6c 100644 --- a/Projects/CoreKit/Sources/Data/Network/Version/VersionClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Version/VersionClient.swift @@ -5,38 +5,10 @@ // Created by 김민호 on 9/10/24. // -import Foundation +import DependenciesMacros -import Dependencies -import Moya - -// MARK: - Dependency Values -extension DependencyValues { - public var versionClient: VersionClient { - get { self[VersionClient.self] } - set { self[VersionClient.self] = newValue } - } -} -/// 알람에 관련한 API를 처리하는 Client +@DependencyClient public struct VersionClient { public var 버전체크: @Sendable () async throws -> VersionResponse } -extension VersionClient: DependencyKey { - public static let liveValue: Self = { - let nonProvider = MoyaProvider.buildNonToken() - - return Self( - 버전체크: { - try await nonProvider.request(.버전체크) - } - ) - }() - - public static let previewValue: Self = { - Self( - 버전체크: { .mock } - ) - }() -} - diff --git a/Projects/DSKit/Project.swift b/Projects/DSKit/Project.swift index 0c0dd71c..eddcf7c3 100644 --- a/Projects/DSKit/Project.swift +++ b/Projects/DSKit/Project.swift @@ -26,7 +26,8 @@ let project = Project( // TODO: 의존성 추가 .project(target: "Util", path: .relativeToRoot("Projects/Util")), .external(name: "NukeUI") - ] + ], + settings: .settings() ) ] ) diff --git a/Projects/DSKit/Sources/Components/PokitLinkCard.swift b/Projects/DSKit/Sources/Components/PokitLinkCard.swift index 5dad4313..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 { @@ -34,7 +37,11 @@ public struct PokitLinkCard: View { @MainActor private var buttonLabel: some View { HStack(spacing: 12) { - thumbleNail + if let url = URL(string: link.thumbNail) { + thumbleNail(url: url) + } else { + placeholder + } VStack(spacing: 8) { HStack { @@ -105,27 +112,24 @@ public struct PokitLinkCard: View { } @MainActor - private var thumbleNail: some View { - LazyImage(url: .init(string: link.thumbNail)) { phase in + private func thumbleNail(url: URL) -> some View { + 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 { - ZStack { - Color.pokit(.bg(.disable)) - - PokitSpinner() - .foregroundStyle(.pokit(.icon(.brand))) - .frame(width: 48, height: 48) - } + placeholder } } .animation(.pokitDissolve, value: phase.image) + .frame(width: 124, height: 94) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } - .frame(width: 124, height: 94) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } private var divider: some View { @@ -134,6 +138,18 @@ public struct PokitLinkCard: View { .frame(height: 1) } + private var placeholder: some View { + ZStack { + Color.pokit(.bg(.disable)) + + PokitSpinner() + .foregroundStyle(.pokit(.icon(.brand))) + .frame(width: 48, height: 48) + } + .frame(width: 124, height: 94) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + @ViewBuilder public func divider(isFirst: Bool, isLast: Bool) -> some View { let edge: Edge.Set = isFirst ? .bottom : isLast ? .top : .vertical diff --git a/Projects/DSKit/Sources/Components/PokitLinkPreview.swift b/Projects/DSKit/Sources/Components/PokitLinkPreview.swift index 406ccb98..ea17a12c 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))) @@ -50,6 +51,7 @@ public struct PokitLinkPreview: View { .animation(.pokitDissolve, value: phase.image) } .frame(width: 124, height: 108) + .clipped() info(title: title) diff --git a/Projects/Domain/Project.swift b/Projects/Domain/Project.swift index 02662fef..baec5764 100644 --- a/Projects/Domain/Project.swift +++ b/Projects/Domain/Project.swift @@ -26,7 +26,7 @@ let project = Project( .project(target: "SharedThirdPartyLib", path: .relativeToRoot("Projects/SharedThirdPartyLib")), .project(target: "CoreKit", path: .relativeToRoot("Projects/CoreKit")) ], - settings: .settings + settings: .settings() ) ] ) 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 66a5bb7b..5de0c984 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 @@ -17,13 +18,13 @@ public struct CategoryDetailFeature { /// - Dependency @Dependency(\.dismiss) private var dismiss - @Dependency(\.pasteboard) + @Dependency(PasteboardClient.self) private var pasteboard - @Dependency(\.categoryClient) + @Dependency(CategoryClient.self) private var categoryClient - @Dependency(\.contentClient) + @Dependency(ContentClient.self) private var contentClient - @Dependency(\.kakaoShareClient) + @Dependency(KakaoShareClient.self) private var kakaoShareClient /// - State @ObservableState @@ -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,45 +79,47 @@ public struct CategoryDetailFeature { case async(AsyncAction) case scope(ScopeAction) case delegate(DelegateAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: BindableAction, Equatable { - /// - Binding case binding(BindingAction) - /// - Button Tapped - case categoryKebobButtonTapped(PokitDeleteBottomSheet.SheetType, selectedItem: BaseContentItem?) - case categorySelectButtonTapped - case categorySelected(BaseCategoryItem) - case filterButtonTapped - case contentItemTapped(BaseContentItem) case dismiss - case onAppear case pagenation - case 링크_공유_완료 + case 카테고리_케밥_버튼_눌렀을때(PokitDeleteBottomSheet.SheetType, selectedItem: BaseContentItem?) + case 카테고리_선택_버튼_눌렀을때 + case 카테고리_선택했을때(BaseCategoryItem) + case 필터_버튼_눌렀을때 + case 컨텐츠_항목_눌렀을때(BaseContentItem) + case 뷰가_나타났을때 + case 링크_공유_완료되었을때 } public enum InnerAction: Equatable { - case pokitCategorySheetPresented(Bool) - case pokitCategorySelectSheetPresented(Bool) - case pokitDeleteSheetPresented(Bool) - case 카테고리_목록_조회_결과(BaseCategoryListInquiry) - case 카테고리_내_컨텐츠_목록_갱신(BaseContentListInquiry) - case 컨텐츠_삭제_반영(id: Int) - case pagenation_네트워크_결과(BaseContentListInquiry) + case 카테고리_시트_활성화(Bool) + case 카테고리_선택_시트_활성화(Bool) + case 카테고리_삭제_시트_활성화(Bool) + + case 카테고리_목록_조회_API_반영(BaseCategoryListInquiry) + case 카테고리_내_컨텐츠_목록_조회_API_반영(BaseContentListInquiry) + case 컨텐츠_삭제_API_반영(id: Int) + case pagenation_API_반영(BaseContentListInquiry) case pagenation_초기화 } public enum AsyncAction: Equatable { - case 카테고리_내_컨텐츠_목록_조회 - case 컨텐츠_삭제(id: Int) - case pagenation_네트워크 + case 카테고리_내_컨텐츠_목록_조회_API + case 카테고리_목록_조회_API + case 컨텐츠_삭제_API(id: Int) case 페이징_재조회 + 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 @@ -180,47 +181,44 @@ private extension CategoryDetailFeature { case .binding: return .none - case let .categoryKebobButtonTapped(selectedType, selectedItem): + case let .카테고리_케밥_버튼_눌렀을때(selectedType, selectedItem): state.kebobSelectedType = selectedType state.selectedContentItem = selectedItem - return .run { send in await send(.inner(.pokitCategorySheetPresented(true))) } + return .run { send in await send(.inner(.카테고리_시트_활성화(true))) } - case .categorySelectButtonTapped: - return .send(.inner(.pokitCategorySelectSheetPresented(true))) + case .카테고리_선택_버튼_눌렀을때: + return .send(.inner(.카테고리_선택_시트_활성화(true))) - case .categorySelected(let item): - state.domain.contentList.data = nil + case .카테고리_선택했을때(let item): state.domain.category = item return .run { send in - await send(.inner(.pagenation_초기화)) - await send(.inner(.pokitCategorySelectSheetPresented(false))) + await send(.inner(.pagenation_초기화), animation: .pokitDissolve) + await send(.async(.카테고리_내_컨텐츠_목록_조회_API)) + await send(.inner(.카테고리_선택_시트_활성화(false))) } - case .filterButtonTapped: + case .필터_버튼_눌렀을때: state.isFilterSheetPresented.toggle() return .none - case .contentItemTapped(let selectedItem): + case .컨텐츠_항목_눌렀을때(let selectedItem): return .run { send in await send(.delegate(.contentItemTapped(selectedItem))) } case .dismiss: return .run { _ in await dismiss() } - case .onAppear: - return .run { send in - let request = BasePageableRequest(page: 0, size: 30, sort: ["createdAt,desc"]) - let response = try await categoryClient.카테고리_목록_조회(request, true).toDomain() - await send(.async(.카테고리_내_컨텐츠_목록_조회)) - await send(.inner(.카테고리_목록_조회_결과(response))) - - for await _ in self.pasteboard.changes() { - let url = try await pasteboard.probableWebURL() - await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) - } - } + case .뷰가_나타났을때: + /// 단순 조회 액션들의 나열이기 때문에 merge로 우선 처리 + return .merge( + .send(.async(.카테고리_내_컨텐츠_목록_조회_API)), + .send(.async(.카테고리_목록_조회_API)), + .send(.async(.클립보드_감지)) + ) case .pagenation: - return .run { send in await send(.async(.pagenation_네트워크)) } - case .링크_공유_완료: + state.domain.pageable.page += 1 + return .send(.async(.카테고리_내_컨텐츠_목록_조회_API)) + + case .링크_공유_완료되었을때: state.shareSheetItem = nil return .none } @@ -229,85 +227,98 @@ private extension CategoryDetailFeature { /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case let .pokitCategorySheetPresented(presented): + case let .카테고리_시트_활성화(presented): state.isCategorySheetPresented = presented return .none - case let .pokitDeleteSheetPresented(presented): + case let .카테고리_삭제_시트_활성화(presented): state.isPokitDeleteSheetPresented = presented return .none - case let .pokitCategorySelectSheetPresented(presented): + case let .카테고리_선택_시트_활성화(presented): state.isCategorySelectSheetPresented = presented return .none - case let .카테고리_목록_조회_결과(response): + case let .카테고리_목록_조회_API_반영(response): state.domain.categoryListInQuiry = response guard let first = response.data?.first(where: { item in item.id == state.domain.category.id }) else { return .none } state.domain.category = first return .none - case .카테고리_내_컨텐츠_목록_갱신(let contentList): + + 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 .컨텐츠_삭제_반영(id: let id): + + 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 state.kebobSelectedType = nil return .none - case .pagenation_네트워크_결과(let contentList): + case .pagenation_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 + newList.forEach { state.contents.append(.init(content: $0)) } return .none + case .pagenation_초기화: state.domain.pageable.page = 0 state.domain.contentList.data = nil - return .send(.async(.카테고리_내_컨텐츠_목록_조회)) + state.isLoading = true + state.contents.removeAll() + return .none } } /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .카테고리_내_컨텐츠_목록_조회: + case .카테고리_목록_조회_API: + return .run { send in + let request = BasePageableRequest(page: 0, size: 30, sort: ["createdAt,desc"]) + let response = try await categoryClient.카테고리_목록_조회(request, true).toDomain() + await send(.inner(.카테고리_목록_조회_API_반영(response))) + } + + case .카테고리_내_컨텐츠_목록_조회_API: return .run { [ id = state.domain.category.id, pageable = state.domain.pageable, condition = state.domain.condition ] send in + let request = BasePageableRequest( + page: pageable.page, + size: pageable.size, + sort: pageable.sort + ) + let conditionRequest = BaseConditionRequest(categoryIds: condition.categoryIds, isRead: condition.isUnreadFlitered, favorites: condition.isFavoriteFlitered) let contentList = try await contentClient.카테고리_내_컨텐츠_목록_조회( - "\(id)", - BasePageableRequest( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ), - BaseConditionRequest( - categoryIds: condition.categoryIds, - isRead: condition.isUnreadFlitered, - favorites: condition.isFavoriteFlitered - ) + "\(id)", request, conditionRequest ).toDomain() - if pageable.page == 0 { - await send(.inner(.카테고리_내_컨텐츠_목록_갱신(contentList)), animation: .pokitDissolve) - } else { - await send(.inner(.pagenation_네트워크_결과(contentList))) - } + pageable.page == 0 + ? await send(.inner(.카테고리_내_컨텐츠_목록_조회_API_반영(contentList)), animation: .pokitDissolve) + : await send(.inner(.pagenation_API_반영(contentList))) } - case .컨텐츠_삭제(id: let id): - return .run { [id] send in - let _ = try await contentClient.컨텐츠_삭제("\(id)") - await send(.inner(.컨텐츠_삭제_반영(id: id)), animation: .pokitSpring) + + case let .컨텐츠_삭제_API(contentId): + return .run { send in + let _ = try await contentClient.컨텐츠_삭제("\(contentId)") + await send(.inner(.컨텐츠_삭제_API_반영(id: contentId)), animation: .pokitSpring) } - case .pagenation_네트워크: - state.domain.pageable.page += 1 - return .send(.async(.카테고리_내_컨텐츠_목록_조회)) + case .페이징_재조회: return .run { [ pageable = state.domain.pageable, @@ -345,7 +356,15 @@ private extension CategoryDetailFeature { contentItems?.data = items + newItems } guard let contentItems else { return } - await send(.inner(.카테고리_내_컨텐츠_목록_갱신(contentItems)), animation: .pokitSpring) + await send(.inner(.카테고리_내_컨텐츠_목록_조회_API_반영(contentItems)), animation: .pokitSpring) + } + + case .클립보드_감지: + return .run { send in + for await _ in self.pasteboard.changes() { + let url = try await pasteboard.probableWebURL() + await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) + } } } } @@ -384,18 +403,18 @@ private extension CategoryDetailFeature { switch type { case .링크삭제: guard let content else { return } - await send(.inner(.pokitCategorySheetPresented(false))) + await send(.inner(.카테고리_시트_활성화(false))) await send(.delegate(.링크수정(contentId: content.id))) case .포킷삭제: - await send(.inner(.pokitCategorySheetPresented(false))) + await send(.inner(.카테고리_시트_활성화(false))) await send(.delegate(.포킷수정(category))) } } case .deleteCellButtonTapped: return .run { send in - await send(.inner(.pokitCategorySheetPresented(false))) - await send(.inner(.pokitDeleteSheetPresented(true))) + await send(.inner(.카테고리_시트_활성화(false))) + await send(.inner(.카테고리_삭제_시트_활성화(true))) } default: return .none @@ -404,7 +423,7 @@ private extension CategoryDetailFeature { case .categoryDeleteBottomSheet(let delegateAction): switch delegateAction { case .cancelButtonTapped: - return .run { send in await send(.inner(.pokitDeleteSheetPresented(false))) } + return .run { send in await send(.inner(.카테고리_삭제_시트_활성화(false))) } case .deleteButtonTapped: guard let selectedType = state.kebobSelectedType else { @@ -419,12 +438,12 @@ private extension CategoryDetailFeature { state.isPokitDeleteSheetPresented = false return .none } - return .send(.async(.컨텐츠_삭제(id: selectedItem.id))) + return .send(.async(.컨텐츠_삭제_API(id: selectedItem.id))) case .포킷삭제: state.isPokitDeleteSheetPresented = false state.kebobSelectedType = nil return .run { [categoryId = state.domain.category.id] send in - await send(.inner(.pokitDeleteSheetPresented(false))) + await send(.inner(.카테고리_삭제_시트_활성화(false))) await send(.delegate(.포킷삭제)) try await categoryClient.카테고리_삭제(categoryId) } @@ -433,10 +452,10 @@ private extension CategoryDetailFeature { /// - 필터 버튼을 눌렀을 때 case .filterBottomSheet(let delegateAction): switch delegateAction { - case .dismissButtonTapped: + case .dismiss: state.isFilterSheetPresented.toggle() return .none - case let .okButtonTapped(type, bookMarkSelected, unReadSelected): + case let .확인_버튼_눌렀을때(type, bookMarkSelected, unReadSelected): state.isFilterSheetPresented.toggle() state.domain.pageable.sort = [ type == .최신순 ? "createdAt,desc" : "createdAt,asc" @@ -444,8 +463,20 @@ private extension CategoryDetailFeature { state.sortType = type state.domain.condition.isFavoriteFlitered = bookMarkSelected state.domain.condition.isUnreadFlitered = unReadSelected - return .send(.inner(.pagenation_초기화), animation: .pokitDissolve) + return .concatenate( + .send(.inner(.pagenation_초기화), animation: .pokitDissolve), + .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 1cbc0fb5..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 @@ -45,7 +46,7 @@ public extension CategoryDetailView { if let shareURL = URL(string: content.data) { PokitShareSheet( items: [shareURL], - completion: { send(.링크_공유_완료) } + completion: { send(.링크_공유_완료되었을때) } ) .presentationDetents([.medium, .large]) } @@ -55,7 +56,7 @@ public extension CategoryDetailView { PokitCategorySheet( selectedItem: nil, list: categories.elements, - action: { send(.categorySelected($0)) } + action: { send(.카테고리_선택했을때($0)) } ) .presentationDragIndicator(.visible) } else { @@ -77,7 +78,7 @@ public extension CategoryDetailView { delegateSend: { store.send(.scope(.filterBottomSheet($0))) } ) } - .task { await send(.onAppear).finish() } + .task { await send(.뷰가_나타났을때).finish() } } } } @@ -92,7 +93,10 @@ private extension CategoryDetailView { ) } PokitHeaderItems(placement: .trailing) { - PokitToolbarButton(.icon(.kebab), action: { send(.categoryKebobButtonTapped(.포킷삭제, selectedItem: nil)) }) + PokitToolbarButton( + .icon(.kebab), + action: { send(.카테고리_케밥_버튼_눌렀을때(.포킷삭제, selectedItem: nil)) } + ) } } .padding(.top, 8) @@ -102,7 +106,7 @@ private extension CategoryDetailView { VStack(spacing: 4) { HStack(spacing: 8) { /// cateogry title - Button(action: { send(.categorySelectButtonTapped) }) { + Button(action: { send(.카테고리_선택_버튼_눌렀을때) }) { Text(store.category.categoryName) .foregroundStyle(.pokit(.text(.primary))) .pokitFont(.title1) @@ -125,7 +129,7 @@ private extension CategoryDetailView { state: .filled(.primary), size: .small, shape: .round, - action: { send(.filterButtonTapped) } + action: { send(.필터_버튼_눌렀을때) } ) } } @@ -133,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, @@ -148,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(.contentItemTapped(content)) }, - kebabAction: { send(.categoryKebobButtonTapped(.링크삭제, selectedItem: content)) } + ContentCardView( + store: store, + isFirst: isFirst, + isLast: isLast ) - .divider(isFirst: isFirst, isLast: isLast) - .pokitScrollTransition(.opacity) } if store.hasNext { diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/Sheet/CategoryFilterSheet.swift b/Projects/Feature/FeatureCategoryDetail/Sources/Sheet/CategoryFilterSheet.swift index 7eee5210..110c63e8 100644 --- a/Projects/Feature/FeatureCategoryDetail/Sources/Sheet/CategoryFilterSheet.swift +++ b/Projects/Feature/FeatureCategoryDetail/Sources/Sheet/CategoryFilterSheet.swift @@ -66,7 +66,7 @@ private extension CategoryFilterSheet { Spacer() } .overlay(alignment: .topTrailing) { - Button(action: { delegateSend?(.dismissButtonTapped) }) { + Button(action: { delegateSend?(.dismiss) }) { Image(.icon(.x)) } .buttonStyle(.plain) @@ -105,7 +105,7 @@ private extension CategoryFilterSheet { "확인", state: .filled(.primary), action: { delegateSend?( - .okButtonTapped( + .확인_버튼_눌렀을때( self.sortType, bookMarkSelected: self.isBookMarkSelected, unReadSelected: self.isUnReadSelected @@ -174,8 +174,8 @@ private extension CategoryFilterSheet { //MARK: - Delegate public extension CategoryFilterSheet { enum Delegate: Equatable { - case dismissButtonTapped - case okButtonTapped(SortType, bookMarkSelected: Bool, unReadSelected: Bool) + case dismiss + case 확인_버튼_눌렀을때(SortType, bookMarkSelected: Bool, unReadSelected: Bool) } } //MARK: - Preview diff --git a/Projects/Feature/FeatureCategoryDetailTests/Resources/info.plist b/Projects/Feature/FeatureCategoryDetailTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureCategoryDetailTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTests.swift b/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTests.swift new file mode 100644 index 00000000..8a0522f1 --- /dev/null +++ b/Projects/Feature/FeatureCategoryDetailTests/Sources/FeatureCategoryDetailTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureCategoryDetail + +final class FeatureCategoryDetailTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift index 0ab139b2..b6413230 100644 --- a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift +++ b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift @@ -12,15 +12,15 @@ import Domain import CoreKit import Util -/// - 사용되는 API 목록 -/// 1. Profile 🎨 -/// 2. 포킷 생성 🖨️ @Reducer public struct PokitCategorySettingFeature { /// - Dependency - @Dependency(\.dismiss) var dismiss - @Dependency(\.pasteboard) var pasteboard - @Dependency(\.categoryClient) var categoryClient + @Dependency(\.dismiss) + var dismiss + @Dependency(PasteboardClient.self) + var pasteboard + @Dependency(CategoryClient.self) + var categoryClient /// - State @ObservableState public struct State: Equatable { @@ -84,20 +84,22 @@ public struct PokitCategorySettingFeature { public enum View: BindableAction, Equatable { case binding(BindingAction) case dismiss - case profileSettingButtonTapped - case saveButtonTapped - case onAppear + case 프로필_설정_버튼_눌렀을때 + case 저장_버튼_눌렀을때 + case 뷰가_나타났을때 } public enum InnerAction: Equatable { - case 카테고리_목록_조회_결과(BaseCategoryListInquiry) - case 프로필_목록_조회_결과(images: [BaseCategoryImage]) + case 카테고리_목록_조회_API_반영(BaseCategoryListInquiry) + case 프로필_목록_조회_API_반영(images: [BaseCategoryImage]) case 포킷_오류_핸들링(BaseError) case 카테고리_인메모리_저장(BaseCategoryItem) } public enum AsyncAction: Equatable { - case 프로필_목록_조회 + case 카테고리_목록_조회_API + case 프로필_목록_조회_API + case 클립보드_감지 } public enum ScopeAction: Equatable { @@ -143,7 +145,6 @@ public struct PokitCategorySettingFeature { public var body: some ReducerOf { BindingReducer(action: \.view) Reduce(self.core) - ._printChanges() } } //MARK: - FeatureAction Effect @@ -157,12 +158,11 @@ private extension PokitCategorySettingFeature { case .dismiss: return .run { _ in await dismiss() } - case .profileSettingButtonTapped: - /// [Profile 🎨]1. 프로필 목록 조회 API 호출 + case .프로필_설정_버튼_눌렀을때: state.isProfileSheetPresented.toggle() return .none - case .saveButtonTapped: + case .저장_버튼_눌렀을때: return .run { [domain = state.domain, type = state.type] send in switch type { @@ -183,12 +183,14 @@ private extension PokitCategorySettingFeature { ) await send(.inner(.카테고리_인메모리_저장(responseToCategoryDomain))) await send(.delegate(.settingSuccess)) + case .수정: guard let categoryId = domain.categoryId else { return } guard let image = domain.categoryImage else { return } let request = CategoryEditRequest(categoryName: domain.categoryName, categoryImageId: image.id) let _ = try await categoryClient.카테고리_수정(categoryId, request) await send(.delegate(.settingSuccess)) + case .공유추가: guard let categoryId = domain.categoryId else { return } guard let image = domain.categoryImage else { return } @@ -202,31 +204,24 @@ private extension PokitCategorySettingFeature { await send(.delegate(.settingSuccess)) } } catch: { error, send in - guard let errorResponse = error as? ErrorResponse else { - return - } + guard let errorResponse = error as? ErrorResponse else { return } await send(.inner(.포킷_오류_핸들링(BaseError(response: errorResponse)))) } - case .onAppear: - return .run { send in - let pageRequest = BasePageableRequest(page: 0, size: 100, sort: ["desc"]) - let response = try await categoryClient.카테고리_목록_조회(pageRequest, true).toDomain() - await send(.inner(.카테고리_목록_조회_결과(response))) - await send(.async(.프로필_목록_조회)) - - for await _ in self.pasteboard.changes() { - let url = try await pasteboard.probableWebURL() - await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) - } - } + case .뷰가_나타났을때: + /// 단순 조회API들의 나열이라 merge사용 + return .merge( + .send(.async(.카테고리_목록_조회_API)), + .send(.async(.프로필_목록_조회_API)), + .send(.async(.클립보드_감지)) + ) } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case let .프로필_목록_조회_결과(images): + case let .프로필_목록_조회_API_반영(images): state.domain.imageList = images guard let _ = state.selectedProfile else { @@ -234,9 +229,11 @@ private extension PokitCategorySettingFeature { return .none } return .none - case let .카테고리_목록_조회_결과(response): + + case let .카테고리_목록_조회_API_반영(response): state.domain.categoryListInQuiry = response return .none + case let .포킷_오류_핸들링(baseError): state.pokitNameTextInpuState = .error(message: baseError.message) return .none @@ -250,11 +247,26 @@ private extension PokitCategorySettingFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .프로필_목록_조회: + case .카테고리_목록_조회_API: return .run { send in - let a = try await categoryClient.카테고리_프로필_목록_조회() - let b = a.map { $0.toDomain() } - await send(.inner(.프로필_목록_조회_결과(images: b))) + let pageRequest = BasePageableRequest(page: 0, size: 100, sort: ["desc"]) + let response = try await categoryClient.카테고리_목록_조회(pageRequest, true).toDomain() + await send(.inner(.카테고리_목록_조회_API_반영(response))) + } + + case .프로필_목록_조회_API: + return .run { send in + let response = try await categoryClient.카테고리_프로필_목록_조회() + let images = response.map { $0.toDomain() } + await send(.inner(.프로필_목록_조회_API_반영(images: images))) + } + + case .클립보드_감지: + return .run { send in + for await _ in self.pasteboard.changes() { + let url = try await pasteboard.probableWebURL() + await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) + } } } } @@ -262,7 +274,7 @@ private extension PokitCategorySettingFeature { /// - Scope Effect func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { switch action { - case .profile(.imageSelected(let imageInfo)): + case .profile(.이미지_선택했을때(let imageInfo)): state.isProfileSheetPresented = false state.selectedProfile = imageInfo return .none diff --git a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingView.swift b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingView.swift index 4e92795b..e0244982 100644 --- a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingView.swift +++ b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingView.swift @@ -45,7 +45,7 @@ public extension PokitCategorySettingView { delegateSend: { store.send(.scope(.profile($0))) } ) } - .task { await send(.onAppear).finish() } + .task { await send(.뷰가_나타났을때).finish() } } } } @@ -101,7 +101,7 @@ private extension PokitCategorySettingView { .foregroundStyle( .pokit(.icon(.inverseWh)) ) - Button(action: { send(.profileSettingButtonTapped) }) { + Button(action: { send(.프로필_설정_버튼_눌렀을때) }) { Image(.icon(.edit)) .resizable() .frame(width: 18, height: 18) @@ -166,7 +166,7 @@ private extension PokitCategorySettingView { state: !store.categoryName.isEmpty && store.selectedProfile != nil ? .filled(.primary) : .disable, - action: { send(.saveButtonTapped) } + action: { send(.저장_버튼_눌렀을때) } ) } /// 내포킷 Item diff --git a/Projects/Feature/FeatureCategorySetting/Sources/Sheet/ProfileBottomSheet.swift b/Projects/Feature/FeatureCategorySetting/Sources/Sheet/ProfileBottomSheet.swift index c1d8dc26..6807985f 100644 --- a/Projects/Feature/FeatureCategorySetting/Sources/Sheet/ProfileBottomSheet.swift +++ b/Projects/Feature/FeatureCategorySetting/Sources/Sheet/ProfileBottomSheet.swift @@ -45,7 +45,7 @@ public extension ProfileBottomSheet { transaction: .init(animation: .pokitDissolve) ) { phase in if let image = phase.image { - Button(action: { delegateSend?(.imageSelected(item)) }) { + Button(action: { delegateSend?(.이미지_선택했을때(item)) }) { image .resizable() .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) @@ -91,7 +91,7 @@ public extension ProfileBottomSheet { //MARK: - Delegate public extension ProfileBottomSheet { enum Delegate: Equatable { - case imageSelected(BaseCategoryImage) + case 이미지_선택했을때(BaseCategoryImage) } } //MARK: - Preview diff --git a/Projects/Feature/FeatureCategorySettingTests/Resources/info.plist b/Projects/Feature/FeatureCategorySettingTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureCategorySettingTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTests.swift b/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTests.swift new file mode 100644 index 00000000..b4eb7253 --- /dev/null +++ b/Projects/Feature/FeatureCategorySettingTests/Sources/FeatureCategorySettingTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureCategorySetting + +final class FeatureCategorySettingTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift b/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingFeature.swift index 31d42f88..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 @@ -16,30 +17,21 @@ public struct CategorySharingFeature { /// - Dependency @Dependency(\.dismiss) private var dismiss - @Dependency(\.categoryClient) + @Dependency(CategoryClient.self) private var categoryClient - @Dependency(\.contentClient) + @Dependency(ContentClient.self) private var contentClient /// - State @ObservableState public struct State: Equatable { fileprivate var domain: CategorySharing - var category: CategorySharing.Category { - get { domain.sharedCategory.category } - } - var contents: IdentifiedArrayOf? { - var identifiedArray = IdentifiedArrayOf() - domain.sharedCategory.contentList.data.forEach { content in - identifiedArray.append(content) - } - return identifiedArray - } - var hasNext: Bool { - get { domain.sharedCategory.contentList.hasNext } - } + var category: CategorySharing.Category { domain.sharedCategory.category } + 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( @@ -60,30 +52,32 @@ public struct CategorySharingFeature { case async(AsyncAction) case scope(ScopeAction) case delegate(DelegateAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: Equatable, BindableAction { - case 저장버튼_클릭 - case 컨텐츠_아이템_클릭(CategorySharing.Content) - case 뒤로가기버튼_클릭 - case 경고_확인버튼_클릭 - - case 다음페이지_로딩_onAppear - case binding(BindingAction) + case dismiss + + case 저장_버튼_눌렀을때 + case 경고_확인버튼_눌렀을때 + case 페이지_로딩중일때 + case 뷰가_나타났을때 } public enum InnerAction: Equatable { - case 공유받은_카테고리_갱신(CategorySharing.SharedCategory) + case 공유받은_카테고리_API_반영(CategorySharing.SharedCategory) case 경고_닫음 case 경고_띄움(BaseError) } public enum AsyncAction: Equatable { - case 공유받은_카테고리_조회 + case 공유받은_카테고리_조회_API } - public enum ScopeAction: Equatable { case doNothing } + public enum ScopeAction { + case contents(IdentifiedActionOf) + } public enum DelegateAction: Equatable { case 컨텐츠_아이템_클릭(categoryId: Int, content: CategorySharing.Content) @@ -116,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))) } } @@ -123,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 @@ -130,32 +130,67 @@ private extension CategorySharingFeature { /// - View Effect func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { - case .저장버튼_클릭: + case .binding: + return .none + + case .dismiss: + return .run { _ in await dismiss() } + + case .저장_버튼_눌렀을때: 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 .run { _ in await dismiss() } - case .경고_확인버튼_클릭: + + case .경고_확인버튼_눌렀을때: return .none - case .binding: + + 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 - case .다음페이지_로딩_onAppear: - return .send(.async(.공유받은_카테고리_조회)) } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case let .공유받은_카테고리_갱신(sharedCategory): + 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): state.error = baseError state.isErrorSheetPresented = true return .none + case .경고_닫음: state.isErrorSheetPresented = false state.error = nil @@ -166,28 +201,38 @@ private extension CategorySharingFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .공유받은_카테고리_조회: + case .공유받은_카테고리_조회_API: state.domain.pageable.page += 1 return .run { [ categoryId = state.domain.sharedCategory.category.categoryId, pageable = state.domain.pageable ] send in + let request = BasePageableRequest(page: pageable.page, size: pageable.size, sort: pageable.sort) let sharedCategory = try await categoryClient.공유받은_카테고리_조회( "\(categoryId)", - BasePageableRequest( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ) + request ).toDomain() - await send(.inner(.공유받은_카테고리_갱신(sharedCategory)), animation: .pokitDissolve) + await send(.inner(.공유받은_카테고리_API_반영(sharedCategory)), animation: .pokitDissolve) } } } /// - 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 b00047d8..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,12 +35,13 @@ public extension CategorySharingView { .padding(.top, 12) .pokitNavigationBar { navigationBar } .ignoresSafeArea(edges: .bottom) + .onAppear { send(.뷰가_나타났을때, animation: .pokitDissolve) } .sheet(isPresented: $store.isErrorSheetPresented) { PokitAlert( store.error?.title ?? "에러", message: store.error?.message, confirmText: "확인", - action: { send(.경고_확인버튼_클릭) } + action: { send(.경고_확인버튼_눌렀을때) } ) } } @@ -52,7 +54,7 @@ private extension CategorySharingView { PokitHeaderItems(placement: .leading) { PokitToolbarButton( .icon(.arrowLeft), - action: { send(.뒤로가기버튼_클릭) } + action: { send(.dismiss) } ) } } @@ -79,15 +81,15 @@ private extension CategorySharingView { state: .filled(.primary), size: .medium, shape: .rectangle, - action: { send(.저장버튼_클릭) } + action: { send(.저장_버튼_눌렀을때) } ) } } 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,22 +103,23 @@ 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 { PokitLoading() .padding(.top, 12) - .onAppear { send(.다음페이지_로딩_onAppear) } + .onAppear { send(.페이지_로딩중일때) } } } } diff --git a/Projects/Feature/FeatureCategorySharingTests/Resources/info.plist b/Projects/Feature/FeatureCategorySharingTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureCategorySharingTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTests.swift b/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTests.swift new file mode 100644 index 00000000..e9e2b090 --- /dev/null +++ b/Projects/Feature/FeatureCategorySharingTests/Sources/FeatureCategorySharingTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureCategorySharing + +final class FeatureCategorySharingTests: XCTestCase { + func test() { + + } +} 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 26763554..e6f39e3e 100644 --- a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift +++ b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailFeature.swift @@ -15,11 +15,11 @@ import DSKit @Reducer public struct ContentDetailFeature { /// - Dependency - @Dependency(\.swiftSoup) - private var swiftSoup @Dependency(\.dismiss) private var dismiss - @Dependency(\.contentClient) + @Dependency(SwiftSoupClient.self) + private var swiftSoup + @Dependency(ContentClient.self) private var contentClient /// - State @ObservableState @@ -61,36 +61,35 @@ public struct ContentDetailFeature { /// - Binding case binding(BindingAction) /// - View OnAppeared - case contentDetailViewOnAppeared + case 뷰가_나타났을때 /// - Button Tapped - case sharedButtonTapped - case editButtonTapped - case deleteButtonTapped - case deleteAlertConfirmTapped - case favoriteButtonTapped - case alertCancelButtonTapped + case 공유_버튼_눌렀을때 + case 수정_버튼_눌렀을때 + case 삭제_버튼_눌렀을때 + case 삭제확인_버튼_눌렀을때 + case 즐겨찾기_버튼_눌렀을때 + case 경고시트_해제 - case 링크_공유_완료 + case 링크_공유_완료되었을때 } public enum InnerAction: Equatable { - case fetchMetadata(url: URL) - case parsingInfo(title: String?, imageURL: String?) - case parsingURL - case dismissAlert - case 컨텐츠_상세_조회(content: BaseContentDetail) - case 즐겨찾기_갱신(Bool) - case 링크미리보기_presented + case linkPreview + case 메타데이터_조회_수행(url: URL) + case 메타데이터_조회_반영(title: String?, imageURL: String?) + case URL_유효성_확인 + case 컨텐츠_상세_조회_API_반영(content: BaseContentDetail) + case 즐겨찾기_API_반영(Bool) } public enum AsyncAction: Equatable { - case 컨텐츠_상세_조회(id: Int) - case 즐겨찾기(id: Int) - case 즐겨찾기_취소(id: Int) - case 컨텐츠_삭제(id: Int) + case 컨텐츠_상세_조회_API(id: Int) + case 즐겨찾기_API(id: Int) + case 즐겨찾기_취소_API(id: Int) + case 컨텐츠_삭제_API(id: Int) } - public enum ScopeAction: Equatable { case doNothing } + public enum ScopeAction: Equatable { case 없음 } public enum DelegateAction: Equatable { case editButtonTapped(contentId: Int) @@ -138,49 +137,43 @@ private extension ContentDetailFeature { /// - View Effect func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { - case .contentDetailViewOnAppeared: - guard let id = state.domain.contentId else { + case .뷰가_나타났을때: + 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 .run { send in - await send(.async(.컨텐츠_상세_조회(id: id))) - } - case .sharedButtonTapped: + case .공유_버튼_눌렀을때: state.showShareSheet = true return .none - case .editButtonTapped: + case .수정_버튼_눌렀을때: guard let content = state.domain.content else { return .none } - return .run { [content] send in - await send(.delegate(.editButtonTapped(contentId: content.id))) - } - case .deleteButtonTapped: + return .send(.delegate(.editButtonTapped(contentId: content.id))) + case .삭제_버튼_눌렀을때: state.showAlert = true return .none - case .deleteAlertConfirmTapped: + case .삭제확인_버튼_눌렀을때: guard let id = state.domain.contentId else { return .none } - return .run { send in - await send(.async(.컨텐츠_삭제(id: id))) - } + return .send(.async(.컨텐츠_삭제_API(id: id))) case .binding: return .none - case .favoriteButtonTapped: + case .즐겨찾기_버튼_눌렀을때: guard let content = state.domain.content, let favorites = state.domain.content?.favorites else { return .none } - return .run { send in - if favorites { - await send(.async(.즐겨찾기_취소(id: content.id))) - } else { - await send(.async(.즐겨찾기(id: content.id))) - } - } - case .링크_공유_완료: + return favorites + ? .send(.async(.즐겨찾기_취소_API(id: content.id))) + : .send(.async(.즐겨찾기_API(id: content.id))) + case .링크_공유_완료되었을때: state.showShareSheet = false return .none - case .alertCancelButtonTapped: + case .경고시트_해제: state.showAlert = false return .none } @@ -189,22 +182,21 @@ private extension ContentDetailFeature { /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .fetchMetadata(url: let url): + case .메타데이터_조회_수행(url: let url): return .run { send in /// - 링크에 대한 메타데이터의 제목 및 썸네일 항목 파싱 - let (title, imageURL) = await swiftSoup.parseOGTitleAndImage(url) { - await send(.inner(.링크미리보기_presented), animation: .pokitDissolve) - } - await send( - .inner(.parsingInfo(title: title, imageURL: imageURL)), + async let title = swiftSoup.parseOGTitle(url) + async let imageURL = swiftSoup.parseOGImageURL(url) + try await send( + .inner(.메타데이터_조회_반영(title: title, imageURL: imageURL)), animation: .pokitDissolve ) } - case let .parsingInfo(title: title, imageURL: imageURL): + case let .메타데이터_조회_반영(title: title, imageURL: imageURL): state.linkTitle = title state.linkImageURL = imageURL - return .none - case .parsingURL: + return .send(.inner(.linkPreview), animation: .pokitDissolve) + case .URL_유효성_확인: guard let urlString = state.domain.content?.data, let url = URL(string: urlString) else { /// 🚨 Error Case [1]: 올바른 링크가 아닐 때 @@ -213,20 +205,17 @@ private extension ContentDetailFeature { state.linkImageURL = nil return .none } - return .send(.inner(.fetchMetadata(url: url)), animation: .pokitDissolve) - case .dismissAlert: - state.showAlert = false - return .none - case .컨텐츠_상세_조회(content: let content): + return .send(.inner(.메타데이터_조회_수행(url: url)), animation: .pokitDissolve) + case .컨텐츠_상세_조회_API_반영(content: let content): state.domain.content = content - return .run { send in - await send(.delegate(.컨텐츠_조회_완료)) - await send(.inner(.parsingURL)) - } - case .즐겨찾기_갱신(let favorite): + return .merge( + .send(.delegate(.컨텐츠_조회_완료)), + .send(.inner(.URL_유효성_확인)) + ) + case .즐겨찾기_API_반영(let favorite): state.domain.content?.favorites = favorite return .send(.delegate(.즐겨찾기_갱신_완료)) - case .링크미리보기_presented: + case .linkPreview: state.showLinkPreview = true return .none } @@ -235,22 +224,22 @@ private extension ContentDetailFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .컨텐츠_상세_조회(id: let id): + case .컨텐츠_상세_조회_API(id: let id): return .run { send in let contentResponse = try await contentClient.컨텐츠_상세_조회("\(id)").toDomain() - await send(.inner(.컨텐츠_상세_조회(content: contentResponse))) + await send(.inner(.컨텐츠_상세_조회_API_반영(content: contentResponse))) } - case .즐겨찾기(id: let id): + case .즐겨찾기_API(id: let id): return .run { send in let _ = try await contentClient.즐겨찾기("\(id)") - await send(.inner(.즐겨찾기_갱신(true))) + await send(.inner(.즐겨찾기_API_반영(true))) } - case .즐겨찾기_취소(id: let id): + case .즐겨찾기_취소_API(id: let id): return .run { send in try await contentClient.즐겨찾기_취소("\(id)") - await send(.inner(.즐겨찾기_갱신(false))) + await send(.inner(.즐겨찾기_API_반영(false))) } - case .컨텐츠_삭제(id: let id): + case .컨텐츠_삭제_API(id: let id): return .run { send in try await contentClient.컨텐츠_삭제("\(id)") await send(.delegate(.컨텐츠_삭제_완료)) diff --git a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift index dabd2a2e..c049946f 100644 --- a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift +++ b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift @@ -53,8 +53,8 @@ public extension ContentDetailView { "링크를 정말 삭제하시겠습니까?", message: "함께 저장한 모든 정보가 삭제되며, \n복구하실 수 없습니다.", confirmText: "삭제", - action: { send(.deleteAlertConfirmTapped) }, - cancelAction: { send(.alertCancelButtonTapped) } + action: { send(.삭제확인_버튼_눌렀을때) }, + cancelAction: { send(.경고시트_해제) } ) } .sheet(isPresented: $store.showShareSheet) { @@ -62,13 +62,13 @@ public extension ContentDetailView { let shareURL = URL(string: content.data) { PokitShareSheet( items: [shareURL], - completion: { send(.링크_공유_완료) } + completion: { send(.링크_공유_완료되었을때) } ) .presentationDetents([.medium, .large]) } } .task { - await send(.contentDetailViewOnAppeared, animation: .pokitDissolve).finish() + await send(.뷰가_나타났을때, animation: .pokitDissolve).finish() } } } @@ -174,7 +174,7 @@ private extension ContentDetailView { @ViewBuilder func favorite(favorites: Bool) -> some View { - Button(action: { send(.favoriteButtonTapped, animation: .pokitDissolve) }) { + Button(action: { send(.즐겨찾기_버튼_눌렀을때, animation: .pokitDissolve) }) { Image(favorites ? .icon(.starFill) : .icon(.starFill)) .resizable() .scaledToFit() @@ -195,17 +195,17 @@ private extension ContentDetailView { Group { toolbarButton( .icon(.share), - action: { send(.sharedButtonTapped) } + action: { send(.공유_버튼_눌렀을때) } ) toolbarButton( .icon(.edit), - action: { send(.editButtonTapped) } + action: { send(.수정_버튼_눌렀을때) } ) toolbarButton( .icon(.trash), - action: { send(.deleteButtonTapped) } + action: { send(.삭제_버튼_눌렀을때) } ) } .disabled(store.contentId == nil) diff --git a/Projects/Feature/FeatureContentDetailTests/Resources/info.plist b/Projects/Feature/FeatureContentDetailTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureContentDetailTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTests.swift b/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTests.swift new file mode 100644 index 00000000..96dfa6dd --- /dev/null +++ b/Projects/Feature/FeatureContentDetailTests/Sources/FeatureContentDetailTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureContentDetail + +final class FeatureContentDetailTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift index 4d73e346..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 @@ -17,11 +18,11 @@ public struct ContentListFeature { /// - Dependency @Dependency(\.dismiss) private var dismiss - @Dependency(\.pasteboard) + @Dependency(PasteboardClient.self) private var pasteBoard - @Dependency(\.remindClient) + @Dependency(RemindClient.self) private var remindClient - @Dependency(\.contentClient) + @Dependency(ContentClient.self) private var contentClient /// - State @ObservableState @@ -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,52 +56,54 @@ public struct ContentListFeature { case async(AsyncAction) case scope(ScopeAction) case delegate(DelegateAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: Equatable, BindableAction { /// - Binding case binding(BindingAction) + + case pagenation /// - Button Tapped - case linkCardTapped(content: BaseContentItem) - case kebabButtonTapped(content: BaseContentItem) - case bottomSheetButtonTapped( + case bottomSheet( delegate: PokitBottomSheet.Delegate, content: BaseContentItem ) - case deleteAlertConfirmTapped(content: BaseContentItem) - case sortTextLinkTapped - case backButtonTapped + case 컨텐츠_항목_눌렀을때(content: BaseContentItem) + case 컨텐츠_항목_케밥_버튼_눌렀을때(content: BaseContentItem) + case 컨텐츠_삭제_눌렀을때(content: BaseContentItem) + case 정렬_버튼_눌렀을때 + case dismiss /// - On Appeared - case contentListViewOnAppeared - case pagenation + case 뷰가_나타났을때 - case 링크_공유_완료 + case 링크_공유시트_해제 + case 경고시트_해제 } public enum InnerAction: Equatable { - case dismissBottomSheet - case 컨텐츠_목록_조회(BaseContentListInquiry) - case 컨텐츠_삭제_반영(id: Int) - case pagenation_네트워크_결과(BaseContentListInquiry) - case pagenation_초기화 - case 컨텐츠_목록_갱신(BaseContentListInquiry) - case 컨텐츠_개수_갱신(Int) + case 바텀시트_해제 + case 컨텐츠_목록_조회_API_반영(BaseContentListInquiry) + case 컨텐츠_삭제_API_반영(id: Int) + case 컨텐츠_목록_조회_페이징_API_반영(BaseContentListInquiry) + case 페이징_초기화 + case 컨텐츠_개수_업데이트(Int) } public enum AsyncAction: Equatable { - case 읽지않음_컨텐츠_조회 - case 즐겨찾기_링크모음_조회 - case 컨텐츠_삭제(id: Int) - case pagenation_네트워크 - case 페이징_재조회 - case 컨텐츠_개수_조회 + case 컨텐츠_삭제_API(id: Int) + case 컨텐츠_목록_조회_페이징_API + case 컨텐츠_목록_조회_API + case 컨텐츠_개수_조회_API + case 클립보드_감지 } - public enum ScopeAction: Equatable { + public enum ScopeAction { case bottomSheet( delegate: PokitBottomSheet.Delegate, content: BaseContentItem ) + case contents(IdentifiedActionOf) } public enum DelegateAction: Equatable { @@ -142,12 +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) + .forEach(\.contents, action: \.contents) { + ContentCardFeature() + } } } //MARK: - FeatureAction Effect @@ -155,87 +158,82 @@ private extension ContentListFeature { /// - View Effect func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { - case .kebabButtonTapped(let content): + case .컨텐츠_항목_케밥_버튼_눌렀을때(let content): state.bottomSheetItem = content return .none - case .linkCardTapped(let content): + case .컨텐츠_항목_눌렀을때(let content): return .send(.delegate(.링크상세(content: content))) - case .bottomSheetButtonTapped(let delegate, let content): - return .run { send in - await send(.inner(.dismissBottomSheet)) - await send(.scope(.bottomSheet(delegate: delegate, content: content))) - } - case .deleteAlertConfirmTapped: + case .bottomSheet(let delegate, let content): + return .concatenate( + .send(.inner(.바텀시트_해제)), + .send(.scope(.bottomSheet(delegate: delegate, content: content))) + ) + case .컨텐츠_삭제_눌렀을때: guard let id = state.alertItem?.id else { return .none } - return .run { [id] send in - await send(.async(.컨텐츠_삭제(id: id))) - } + return .send(.async(.컨텐츠_삭제_API(id: id))) case .binding: return .none - case .sortTextLinkTapped: + case .정렬_버튼_눌렀을때: state.isListDescending.toggle() state.domain.pageable.sort = [ state.isListDescending ? "createdAt,desc" : "createdAt,asc" ] - return .send(.inner(.pagenation_초기화), animation: .pokitDissolve) - case .backButtonTapped: + return .send(.inner(.페이징_초기화), animation: .pokitDissolve) + case .dismiss: return .run { _ in await dismiss() } - case .contentListViewOnAppeared: - return .run { [type = state.contentType] send in - switch type { - case .unread: - await send(.async(.읽지않음_컨텐츠_조회), animation: .pokitDissolve) - case .favorite: - await send(.async(.즐겨찾기_링크모음_조회), animation: .pokitDissolve) - } - - for await _ in self.pasteBoard.changes() { - let url = try await pasteBoard.probableWebURL() - await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) - } - } - + case .뷰가_나타났을때: + return .merge( + .send(.async(.컨텐츠_개수_조회_API)), + .send(.async(.컨텐츠_목록_조회_API)), + .send(.async(.클립보드_감지)) + ) case .pagenation: - return .send(.async(.pagenation_네트워크)) - case .링크_공유_완료: + return .send(.async(.컨텐츠_목록_조회_페이징_API)) + case .링크_공유시트_해제: state.shareSheetItem = nil return .none + case .경고시트_해제: + state.alertItem = nil + return .none } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .dismissBottomSheet: + case .바텀시트_해제: state.bottomSheetItem = nil return .none - case .컨텐츠_목록_조회(let contentList): + 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 + + newList.forEach { state.contents.append(.init(content: $0)) } return .none - case .컨텐츠_삭제_반영(id: let id): + 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 .pagenation_네트워크_결과(let contentList): + 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 .pagenation_초기화: + case .페이징_초기화: state.domain.pageable.page = 0 state.domain.contentList.data = nil - switch state.contentType { - case .unread: - return .send(.async(.읽지않음_컨텐츠_조회), animation: .pokitDissolve) - case .favorite: - return .send(.async(.즐겨찾기_링크모음_조회), animation: .pokitDissolve) - } - case let .컨텐츠_목록_갱신(contentList): - state.domain.contentList = contentList - return .none - case let .컨텐츠_개수_갱신(count): + state.isLoading = true + state.contents.removeAll() + return .send(.async(.컨텐츠_목록_조회_API), animation: .pokitDissolve) + case let .컨텐츠_개수_업데이트(count): state.domain.contentCount = count return .none } @@ -244,105 +242,58 @@ private extension ContentListFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .읽지않음_컨텐츠_조회: - return .run { [pageable = state.domain.pageable] send in - await send(.async(.컨텐츠_개수_조회)) - let contentList = try await remindClient.읽지않음_컨텐츠_조회( - BasePageableRequest( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ) - ).toDomain() - await send( - .inner(.컨텐츠_목록_조회(contentList)), - animation: pageable.page == 0 ? .pokitDissolve : nil - ) - } - case .즐겨찾기_링크모음_조회: - return .run { [pageable = state.domain.pageable] send in - await send(.async(.컨텐츠_개수_조회)) - let contentList = try await remindClient.즐겨찾기_링크모음_조회( - BasePageableRequest( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ) - ).toDomain() - await send( - .inner(.컨텐츠_목록_조회(contentList)), - animation: pageable.page == 0 ? .pokitDissolve : nil - ) - } - case .컨텐츠_삭제(id: let id): - return .run { [count = state.domain.contentCount] send in - let newCount = count - 1 - await send(.inner(.컨텐츠_개수_갱신(newCount)), animation: .pokitSpring) - let _ = try await contentClient.컨텐츠_삭제("\(id)") - await send(.inner(.컨텐츠_삭제_반영(id: id)), animation: .pokitSpring) - } + case .컨텐츠_삭제_API(id: let id): + let count = state.domain.contentCount + let newCount = count - 1 + + return .merge( + .send(.inner(.컨텐츠_개수_업데이트(newCount))), + contentDelete(contentId: id) + ) - case .pagenation_네트워크: + case .컨텐츠_목록_조회_페이징_API: state.domain.pageable.page += 1 - return .run { [type = state.contentType] send in + return .run { [ + type = state.contentType, + pageableRequest = BasePageableRequest( + page: state.domain.pageable.page, + size: state.domain.pageable.size, + sort: state.domain.pageable.sort + ) + ] send in + let contentList: BaseContentListInquiry switch type { case .unread: - await send(.async(.읽지않음_컨텐츠_조회)) - break + contentList = try await remindClient.읽지않음_컨텐츠_조회( + pageableRequest + ).toDomain() case .favorite: - await send(.async(.즐겨찾기_링크모음_조회)) - break - } - } - case .페이징_재조회: - return .run { [ - pageable = state.domain.pageable, - contentType = state.contentType - ] send in - await send(.async(.컨텐츠_개수_조회)) - let stream = AsyncThrowingStream { continuation in - Task { - for page in 0...pageable.page { - let paeagableRequest = BasePageableRequest( - page: page, - size: pageable.size, - sort: pageable.sort - ) - switch contentType { - case .favorite: - let contentList = try await remindClient.즐겨찾기_링크모음_조회( - paeagableRequest - ).toDomain() - continuation.yield(contentList) - case .unread: - let contentList = try await remindClient.읽지않음_컨텐츠_조회( - paeagableRequest - ).toDomain() - continuation.yield(contentList) - } - } - continuation.finish() - } + contentList = try await remindClient.즐겨찾기_링크모음_조회( + pageableRequest + ).toDomain() } - 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(.컨텐츠_목록_갱신(contentItems)), animation: .pokitSpring) + + await send(.inner(.컨텐츠_목록_조회_페이징_API_반영(contentList))) } - case .컨텐츠_개수_조회: + case .컨텐츠_목록_조회_API: + return contentListFetch(state: &state) + case .컨텐츠_개수_조회_API: return .run { [ contentType = state.contentType ] send in + let count: Int switch contentType { case .favorite: - let count = try await remindClient.즐겨찾기_컨텐츠_개수_조회().count - await send(.inner(.컨텐츠_개수_갱신(count)), animation: .pokitSpring) + count = try await remindClient.즐겨찾기_컨텐츠_개수_조회().count case .unread: - let count = try await remindClient.읽지않음_컨텐츠_개수_조회().count - await send(.inner(.컨텐츠_개수_갱신(count)), animation: .pokitSpring) + count = try await remindClient.읽지않음_컨텐츠_개수_조회().count + } + + await send(.inner(.컨텐츠_개수_업데이트(count)), animation: .pokitSpring) + } + case .클립보드_감지: + return .run { send in + for await _ in self.pasteBoard.changes() { + let url = try await pasteBoard.probableWebURL() + await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) } } } @@ -365,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 } } @@ -372,11 +331,59 @@ private extension ContentListFeature { func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { switch action { case .컨텐츠_목록_조회: - return .send(.async(.페이징_재조회)) + return .send(.async(.컨텐츠_목록_조회_API)) default: return .none } } + + func contentListFetch(state: inout State) -> Effect { + return .run { [ + pageable = state.domain.pageable, + contentType = state.contentType + ] send in + let stream = AsyncThrowingStream { continuation in + Task { + for page in 0...pageable.page { + let paeagableRequest = BasePageableRequest( + page: page, + size: pageable.size, + sort: pageable.sort + ) + switch contentType { + case .favorite: + let contentList = try await remindClient.즐겨찾기_링크모음_조회( + paeagableRequest + ).toDomain() + continuation.yield(contentList) + case .unread: + let contentList = try await remindClient.읽지않음_컨텐츠_조회( + paeagableRequest + ).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) + } + } + + func contentDelete(contentId: Int) -> Effect { + return .run { send in + let _ = try await contentClient.컨텐츠_삭제("\(contentId)") + await send(.inner(.컨텐츠_삭제_API_반영(id: contentId)), animation: .pokitSpring) + } + } } public extension ContentListFeature { diff --git a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift index 1fb27e51..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) @@ -38,7 +39,7 @@ public extension ContentListView { items: [.share, .edit, .delete], height: 224, delegateSend: { - send(.bottomSheetButtonTapped(delegate: $0, content: content)) + send(.bottomSheet(delegate: $0, content: content)) } ) } @@ -46,7 +47,7 @@ public extension ContentListView { if let shareURL = URL(string: content.data) { PokitShareSheet( items: [shareURL], - completion: { send(.링크_공유_완료) } + completion: { send(.링크_공유시트_해제) } ) .presentationDetents([.medium, .large]) } @@ -55,10 +56,12 @@ public extension ContentListView { PokitAlert( "링크를 정말 삭제하시겠습니까?", message: "함께 저장한 모든 정보가 삭제되며, \n복구하실 수 없습니다.", - confirmText: "삭제" - ) { send(.deleteAlertConfirmTapped(content: content)) } + confirmText: "삭제", + action: { send(.컨텐츠_삭제_눌렀을때(content: content)) }, + cancelAction: { send(.경고시트_해제) } + ) } - .task { await send(.contentListViewOnAppeared, animation: .pokitDissolve).finish() } + .task { await send(.뷰가_나타났을때, animation: .pokitDissolve).finish() } } } } @@ -76,7 +79,7 @@ private extension ContentListView { PokitIconLTextLink( store.isListDescending ? "최신순" : "오래된순", icon: .icon(.align), - action: { send(.sortTextLinkTapped) } + action: { send(.정렬_버튼_눌렀을때) } ) .contentTransition(.numericText()) } @@ -84,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: "즐겨찾기 링크가 없어요!", @@ -97,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(.linkCardTapped(content: content)) }, - kebabAction: { send(.kebabButtonTapped(content: content)) } + ContentCardView( + store: store, + isFirst: isFirst, + isLast: isLast ) - .divider(isFirst: isFirst, isLast: isLast) } if store.hasNext { @@ -128,7 +132,7 @@ private extension ContentListView { PokitHeader(title: store.contentType.title) { PokitHeaderItems(placement: .leading) { PokitToolbarButton(.icon(.arrowLeft)) { - send(.backButtonTapped) + send(.dismiss) } } } diff --git a/Projects/Feature/FeatureContentListTests/Resources/info.plist b/Projects/Feature/FeatureContentListTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureContentListTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureContentListTests/Sources/FeatureContentListTests.swift b/Projects/Feature/FeatureContentListTests/Sources/FeatureContentListTests.swift new file mode 100644 index 00000000..4051d641 --- /dev/null +++ b/Projects/Feature/FeatureContentListTests/Sources/FeatureContentListTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureContentList + +final class FeatureContentListTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift index 4be31b47..089b9fa3 100644 --- a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift +++ b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift @@ -18,22 +18,24 @@ public struct ContentSettingFeature { /// - Dependency @Dependency(\.dismiss) private var dismiss - @Dependency(\.swiftSoup) + @Dependency(SwiftSoupClient.self) private var swiftSoup - @Dependency(\.pasteboard) + @Dependency(PasteboardClient.self) private var pasteboard - @Dependency(\.contentClient) + @Dependency(ContentClient.self) private var contentClient - @Dependency(\.categoryClient) + @Dependency(CategoryClient.self) private var categoryClient /// - State @ObservableState public struct State: Equatable { public init( contentId: Int? = nil, - urlText: String? = nil + urlText: String? = nil, + isShareExtension: Bool = false ) { self.domain = .init(contentId: contentId, data: urlText) + self.isShareExtension = isShareExtension } fileprivate var domain: ContentSetting var urlText: String { @@ -72,6 +74,7 @@ public struct ContentSettingFeature { var saveIsLoading: Bool = false var link: String? var showLinkPreview = false + var isShareExtension: Bool } /// - Action @@ -87,43 +90,44 @@ public struct ContentSettingFeature { /// - Binding case binding(BindingAction) /// - Button Tapped - case pokitSelectButtonTapped - case pokitSelectItemButtonTapped(pokit: BaseCategoryItem) - case contentSettingViewOnAppeared - case saveBottomButtonTapped - case addPokitButtonTapped - case linkCopyButtonTapped + case 포킷선택_버튼_눌렀을때 + case 포킷선택_항목_눌렀을때(pokit: BaseCategoryItem) + case 뷰가_나타났을때 + case 저장_버튼_눌렀을때 + case 포킷추가_버튼_눌렀을때 + case 링크복사_버튼_눌렀을때 - case dismiss + case 뒤로가기_버튼_눌렀을때 } public enum InnerAction: Equatable { - case fetchMetadata(url: URL) - case parsingInfo(title: String?, imageURL: String?) - case parsingURL - case showPopup - case showLinkPopup(URL?) - case updateURLText(String?) - case 컨텐츠_갱신(content: BaseContentDetail) - case 카테고리_갱신(category: BaseCategory) - case 카테고리_목록_갱신(categoryList: BaseCategoryListInquiry) - case 링크미리보기_presented + case linkPopup(URL?) + case linkPreview + case 메타데이터_조회_수행(url: URL) + case 메타데이텨_조회_반영(title: String?, imageURL: String?) + case URL_유효성_확인 + case 링크복사_반영(String?) + case 컨텐츠_상세_조회_API_반영(content: BaseContentDetail) + case 카테고리_상세_조회_API_반영(category: BaseCategory) + case 카테고리_목록_조회_API_반영(categoryList: BaseCategoryListInquiry) case 선택한_포킷_인메모리_삭제 } public enum AsyncAction: Equatable { - case 컨텐츠_상세_조회(id: Int) - case 카테고리_상세_조회(id: Int?, sharedId: Int?) - case 카테고리_목록_조회 - case 컨텐츠_수정 - case 컨텐츠_추가 + case 컨텐츠_상세_조회_API(id: Int) + case 카테고리_상세_조회_API(id: Int?, sharedId: Int?) + case 카테고리_목록_조회_API + case 컨텐츠_수정_API + case 컨텐츠_추가_API + case 클립보드_감지 } - public enum ScopeAction: Equatable { case doNothing } + public enum ScopeAction: Equatable { case 없음 } public enum DelegateAction: Equatable { case 저장하기_완료 case 포킷추가하기 + case dismiss } } @@ -168,79 +172,81 @@ private extension ContentSettingFeature { switch action { case .binding(\.urlText): enum CancelID { case urlTextChanged } - return .run { send in - await send(.inner(.parsingURL)) - } - /// - 1초마다 `urlText`변화의 마지막을 감지하여 이벤트 방출 - .throttle( + return .send(.inner(.URL_유효성_확인)).debounce( + /// - 1초마다 `urlText`변화의 마지막을 감지하여 이벤트 방출 id: CancelID.urlTextChanged, for: 1, - scheduler: DispatchQueue.main, - latest: true + scheduler: DispatchQueue.main ) case .binding: return .none - case .pokitSelectButtonTapped: - return .send(.async(.카테고리_목록_조회)) - case .pokitSelectItemButtonTapped(pokit: let pokit): + case .포킷선택_버튼_눌렀을때: + return .send(.async(.카테고리_목록_조회_API)) + case .포킷선택_항목_눌렀을때(pokit: let pokit): state.selectedPokit = pokit return .none - case .contentSettingViewOnAppeared: - return .run { [id = state.domain.contentId] send in - if let id { - await send(.async(.컨텐츠_상세_조회(id: id))) - } - await send(.async(.카테고리_목록_조회)) - await send(.inner(.parsingURL)) - for await _ in self.pasteboard.changes() { - let url = try await pasteboard.probableWebURL() - await send(.inner(.showLinkPopup(url)), animation: .pokitSpring) - } + case .뷰가_나타났을때: + var mergeEffect: [Effect] = [ + .send(.async(.카테고리_목록_조회_API)), + .send(.inner(.URL_유효성_확인)), + .send(.async(.클립보드_감지)) + ] + if let id = state.domain.contentId { + mergeEffect.append(.send(.async(.컨텐츠_상세_조회_API(id: id)))) } - case .saveBottomButtonTapped: - return .run { [isEdit = state.domain.categoryId != nil] send in - if isEdit { - await send(.async(.컨텐츠_수정)) - } else { - await send(.async(.컨텐츠_추가)) - } - } - case .addPokitButtonTapped: + return .merge(mergeEffect) + case .저장_버튼_눌렀을때: + let isEdit = state.domain.categoryId != nil + + return isEdit + ? .send(.async(.컨텐츠_수정_API)) + : .send(.async(.컨텐츠_추가_API)) + case .포킷추가_버튼_눌렀을때: guard state.domain.categoryTotalCount < 30 else { /// 🚨 Error Case [1]: 포킷 갯수가 30개 이상일 경우 - return .send(.inner(.showPopup), animation: .pokitSpring) + state.showMaxCategoryPopup = true + return .none } + return .send(.delegate(.포킷추가하기)) - - case .dismiss: - return .run { _ in await dismiss() } - case .linkCopyButtonTapped: - return .send(.inner(.updateURLText(state.link))) + case .뒤로가기_버튼_눌렀을때: + return state.isShareExtension + ? .send(.delegate(.dismiss)) + : .run { _ in await dismiss() } + case .링크복사_버튼_눌렀을때: + return .send(.inner(.링크복사_반영(state.link))) } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .fetchMetadata(url: let url): + case let .linkPopup(url): + guard let url else { return .none } + state.link = url.absoluteString + state.showDetectedURLPopup = true + return .none + case .linkPreview: + state.showLinkPreview = true + return .none + case .메타데이터_조회_수행(url: let url): return .run { send in - let (title, imageURL) = await swiftSoup.parseOGTitleAndImage(url) { - await send(.inner(.링크미리보기_presented), animation: .pokitDissolve) - } - await send( - .inner(.parsingInfo(title: title, imageURL: imageURL)), + async let title = swiftSoup.parseOGTitle(url) + async let imageURL = swiftSoup.parseOGImageURL(url) + try await send( + .inner(.메타데이텨_조회_반영(title: title, imageURL: imageURL)), animation: .pokitDissolve ) } - case let .parsingInfo(title: title, imageURL: imageURL): + case let .메타데이텨_조회_반영(title: title, imageURL: imageURL): state.linkTitle = title state.linkImageURL = imageURL if let title, state.domain.title.isEmpty { state.domain.title = title } state.domain.thumbNail = imageURL - return .none - case .parsingURL: + return .send(.inner(.linkPreview), animation: .pokitDissolve) + case .URL_유효성_확인: guard let url = URL(string: state.domain.data), !state.domain.data.isEmpty else { /// 🚨 Error Case [1]: 올바른 링크가 아닐 때 @@ -251,17 +257,14 @@ private extension ContentSettingFeature { state.domain.thumbNail = nil return .none } - return .send(.inner(.fetchMetadata(url: url)), animation: .pokitDissolve) - case .showPopup: - state.showMaxCategoryPopup = true - return .none - case .updateURLText(let urlText): + return .send(.inner(.메타데이터_조회_수행(url: url)), animation: .pokitDissolve) + case .링크복사_반영(let urlText): state.showDetectedURLPopup = false state.link = nil guard let urlText else { return .none } state.domain.data = urlText - return .send(.inner(.parsingURL)) - case .컨텐츠_갱신(content: let content): + return .send(.inner(.URL_유효성_확인)) + case .컨텐츠_상세_조회_API_반영(content: let content): state.domain.content = content state.domain.data = content.data state.domain.contentId = content.id @@ -270,14 +273,13 @@ private extension ContentSettingFeature { state.domain.memo = content.memo state.domain.alertYn = content.alertYn state.contentLoading = false - return .run { [ - id = content.category.categoryId, - sharedCategoryId = state.categoryId - ] send in - await send(.inner(.parsingURL)) - await send(.async(.카테고리_상세_조회(id: id, sharedId: sharedCategoryId))) - } - case .카테고리_갱신(category: let category): + let id = content.category.categoryId + + return .merge( + .send(.inner(.URL_유효성_확인)), + .send(.async(.카테고리_상세_조회_API(id: id, sharedId: state.categoryId))) + ) + case .카테고리_상세_조회_API_반영(category: let category): state.selectedPokit = BaseCategoryItem( id: category.categoryId, userId: 0, @@ -287,7 +289,7 @@ private extension ContentSettingFeature { createdAt: "" ) return .none - case .카테고리_목록_갱신(categoryList: let categoryList): + case .카테고리_목록_조회_API_반영(categoryList: let categoryList): /// - `카테고리_목록_조회`의 filter 옵션을 `false`로 해두었기 때문에 `미분류` 카테고리 또한 항목에서 조회가 가능함 /// [1]. `미분류`에 해당하는 인덱스 번호와 항목을 체크, 없다면 목록갱신이 불가함 @@ -307,15 +309,6 @@ private extension ContentSettingFeature { state.selectedPokit = unclassifiedItem } return .none - case let .showLinkPopup(url): - guard let url else { return .none } - state.link = url.absoluteString - state.showDetectedURLPopup = true - return .none - case .링크미리보기_presented: - state.showLinkPreview = true - return .none - case .선택한_포킷_인메모리_삭제: state.selectedPokit = nil return .none @@ -325,96 +318,75 @@ private extension ContentSettingFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .컨텐츠_상세_조회(id: let id): + case .컨텐츠_상세_조회_API(id: let id): state.contentLoading = true - return .run { [id] send in + return .run { send in let content = try await contentClient.컨텐츠_상세_조회("\(id)").toDomain() - await send(.inner(.컨텐츠_갱신(content: content))) + await send(.inner(.컨텐츠_상세_조회_API_반영(content: content))) } - case let .카테고리_상세_조회(id, sharedId): + case let .카테고리_상세_조회_API(id, sharedId): return .run { send in if let sharedId { let category = try await categoryClient.카테고리_상세_조회("\(sharedId)").toDomain() - await send(.inner(.카테고리_갱신(category: category))) + await send(.inner(.카테고리_상세_조회_API_반영(category: category))) } else if let id { let category = try await categoryClient.카테고리_상세_조회("\(id)").toDomain() - await send(.inner(.카테고리_갱신(category: category))) + await send(.inner(.카테고리_상세_조회_API_반영(category: category))) } - } - case .카테고리_목록_조회: - return .run { [ - pageable = state.domain.pageable, - id = state.domain.categoryId, - sharedId = state.categoryId - ] send in - let categoryList = try await categoryClient.카테고리_목록_조회( - BasePageableRequest( - page: pageable.page, - size: 100, - sort: pageable.sort - ), - false - ).toDomain() - - await send(.async(.카테고리_상세_조회(id: id, sharedId: sharedId))) - await send(.inner(.카테고리_목록_갱신(categoryList: categoryList)), animation: .pokitDissolve) - } - case .컨텐츠_수정: - guard let contentId = state.domain.contentId else { - return .none - } - guard let categoryId = state.selectedPokit?.id else { + case .카테고리_목록_조회_API: + let request = BasePageableRequest( + page: state.domain.pageable.page, + size: 30, + sort: state.domain.pageable.sort + ) + let id = state.domain.categoryId + let sharedId = state.categoryId + return .merge( + .send(.async(.카테고리_상세_조회_API(id: id, sharedId: sharedId))), + categoryListFetch(request: request) + ) + case .컨텐츠_수정_API: + guard let contentId = state.domain.contentId, + let categoryId = state.selectedPokit?.id else { return .none } - return .run { [ - id = contentId, - data = state.domain.data, - title = state.domain.title, - categoryId = categoryId, - memo = state.domain.memo, - alertYn = state.domain.alertYn, - thumbNail = state.domain.thumbNail - ] send in - let _ = try await contentClient.컨텐츠_수정( - "\(id)", - ContentBaseRequest( - data: data, - title: title, - categoryId: categoryId, - memo: memo, - alertYn: alertYn.rawValue, - thumbNail: thumbNail - ) - ) - await send(.inner(.선택한_포킷_인메모리_삭제)) - await send(.delegate(.저장하기_완료)) - } - case .컨텐츠_추가: + let request = ContentBaseRequest( + data: state.domain.data, + title: state.domain.title, + categoryId: categoryId, + memo: state.domain.memo, + alertYn: state.domain.alertYn.rawValue, + thumbNail: state.domain.thumbNail + ) + return .concatenate( + contentEdit(request: request, contentId: contentId), + .send(.inner(.선택한_포킷_인메모리_삭제)), + .send(.delegate(.저장하기_완료)) + ) + case .컨텐츠_추가_API: guard let categoryId = state.selectedPokit?.id else { return .none } - - return .run { [ - data = state.domain.data, - title = state.domain.title, - categoryId = categoryId, - memo = state.domain.memo, - alertYn = state.domain.alertYn, - thumbNail = state.domain.thumbNail - ] send in - let _ = try await contentClient.컨텐츠_추가( - ContentBaseRequest( - data: data, - title: title, - categoryId: categoryId, - memo: memo, - alertYn: alertYn.rawValue, - thumbNail: thumbNail - ) - ) - await send(.inner(.선택한_포킷_인메모리_삭제)) - await send(.delegate(.저장하기_완료)) + let request = ContentBaseRequest( + data: state.domain.data, + title: state.domain.title, + categoryId: categoryId, + memo: state.domain.memo, + alertYn: state.domain.alertYn.rawValue, + thumbNail: state.domain.thumbNail + ) + return .concatenate( + .run { _ in let _ = try await contentClient.컨텐츠_추가(request) }, + .send(.inner(.선택한_포킷_인메모리_삭제)), + .send(.delegate(.저장하기_완료)) + ) + case .클립보드_감지: + return .run { send in + for await _ in self.pasteboard.changes() { + let url = try await pasteboard.probableWebURL() + await send(.inner(.linkPopup(url)), animation: .pokitSpring) + } } } } @@ -428,4 +400,20 @@ private extension ContentSettingFeature { func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { return .none } + + func contentEdit(request: ContentBaseRequest, contentId: Int) -> Effect { + return .run { _ in + let _ = try await contentClient.컨텐츠_수정( + "\(contentId)", + request + ) + } + } + + func categoryListFetch(request: BasePageableRequest) -> Effect { + return .run { send in + let categoryList = try await categoryClient.카테고리_목록_조회(request, false).toDomain() + await send(.inner(.카테고리_목록_조회_API_반영(categoryList: categoryList)), animation: .pokitDissolve) + } + } } diff --git a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift index 705d33b0..7e91b2b5 100644 --- a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift +++ b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift @@ -56,12 +56,13 @@ public extension ContentSettingView { isPresented: $store.showMaxCategoryPopup, type: .text ) + .animation(.pokitSpring, value: store.showMaxCategoryPopup) } else if store.state.showDetectedURLPopup { PokitLinkPopup( "복사한 링크 저장하기", isPresented: $store.showDetectedURLPopup, type: .link(url: store.link ?? ""), - action: { send(.linkCopyButtonTapped, animation: .pokitSpring) } + action: { send(.링크복사_버튼_눌렀을때, animation: .pokitSpring) } ) } } @@ -74,14 +75,14 @@ public extension ContentSettingView { "저장하기", state: isDisable ? .disable : .filled(.primary), isLoading: $store.saveIsLoading, - action: { send(.saveBottomButtonTapped) } + action: { send(.저장_버튼_눌렀을때) } ) .padding(.horizontal, 20) .pokitMaxWidth() } .pokitNavigationBar { navigationBar } .ignoresSafeArea(edges: focusedType == nil ? .bottom : []) - .onAppear { send(.contentSettingViewOnAppeared) } + .onAppear { send(.뷰가_나타났을때) } } } } @@ -90,8 +91,12 @@ private extension ContentSettingView { var navigationBar: some View { PokitHeader(title: store.content == nil ? "링크 추가" : "링크 수정") { PokitHeaderItems(placement: .leading) { - PokitToolbarButton(.icon(.arrowLeft)) { - send(.dismiss) + PokitToolbarButton(.icon( + store.isShareExtension + ? .x + : .arrowLeft + )) { + send(.뒤로가기_버튼_눌렀을때) } } } @@ -134,7 +139,7 @@ private extension ContentSettingView { selectedItem: $store.selectedPokit, label: "포킷", list: store.pokitList, - action: { send(.pokitSelectItemButtonTapped(pokit: $0), animation: .pokitDissolve) } + action: { send(.포킷선택_항목_눌렀을때(pokit: $0), animation: .pokitDissolve) } ) } @@ -144,7 +149,7 @@ private extension ContentSettingView { state: .filled(.primary), size: .large, shape: .rectangle - ) { send(.addPokitButtonTapped, animation: .pokitSpring) } + ) { send(.포킷추가_버튼_눌렀을때, animation: .pokitSpring) } } var memoTextArea: some View { diff --git a/Projects/Feature/FeatureContentSettingTests/Resources/info.plist b/Projects/Feature/FeatureContentSettingTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureContentSettingTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureContentSettingTests/Sources/FeatureContentSettingTests.swift b/Projects/Feature/FeatureContentSettingTests/Sources/FeatureContentSettingTests.swift new file mode 100644 index 00000000..b6810784 --- /dev/null +++ b/Projects/Feature/FeatureContentSettingTests/Sources/FeatureContentSettingTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureContentSetting + +final class FeatureContentSettingTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureLogin/Sources/AgreeToTerms/AgreeToTermsFeature.swift b/Projects/Feature/FeatureLogin/Sources/AgreeToTerms/AgreeToTermsFeature.swift index 21dca3b0..bec21026 100644 --- a/Projects/Feature/FeatureLogin/Sources/AgreeToTerms/AgreeToTermsFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/AgreeToTerms/AgreeToTermsFeature.swift @@ -39,21 +39,21 @@ public struct AgreeToTermsFeature { /// - Binding case binding(BindingAction) /// - Button Tapped - case nextButtonTapped - case backButtonTapped - case 개인정보_동의_버튼_클릭 - case 서비스_이용약관_버튼_클릭 - case 마케팅_정보_수신_버튼_클릭 + case 다음_버튼_눌렀을때 + case 뒤로가기_버튼_눌렀을때 + case 개인정보_동의_버튼_눌렀을때 + case 서비스_이용약관_버튼_눌렀을때 + case 마케팅_정보_수신_버튼_눌렀을때 } public enum InnerAction: Equatable { - case checkAgreements - case personalAndUsageAgreeSelected - case serviceAgreeSelected - case marketingAgreeSelected - case allAgreementSelected + case 동의_체크_확인 + case 개인정보_동의_선택했을때 + case 서비스_이용약관_동의_선택했을때 + case 마케팅_정보_수신_동의_선택했을때 + case 전체_동의_선택했을때 } - public enum AsyncAction: Equatable { case doNothing } - public enum ScopeAction: Equatable { case doNothing } + public enum AsyncAction: Equatable { case 없음 } + public enum ScopeAction: Equatable { case 없음 } public enum DelegateAction: Equatable { case pushRegisterNicknameView } @@ -91,29 +91,29 @@ private extension AgreeToTermsFeature { /// - View Effect func handleViewAction(_ action: Action.ViewAction, state: inout State) -> Effect { switch action { - case .nextButtonTapped: + case .다음_버튼_눌렀을때: return .send(.delegate(.pushRegisterNicknameView)) - case .backButtonTapped: + case .뒤로가기_버튼_눌렀을때: return .run { _ in await self.dismiss() } case .binding(\.isAgreeAllTerms): - return .send(.inner(.allAgreementSelected)) + return .send(.inner(.전체_동의_선택했을때)) case .binding(\.isPersonalAndUsageArgee): - return .send(.inner(.personalAndUsageAgreeSelected)) + return .send(.inner(.개인정보_동의_선택했을때)) case .binding(\.isServiceAgree): - return .send(.inner(.serviceAgreeSelected)) + return .send(.inner(.서비스_이용약관_동의_선택했을때)) case .binding(\.isMarketingAgree): - return .send(.inner(.marketingAgreeSelected)) + return .send(.inner(.마케팅_정보_수신_동의_선택했을때)) case .binding: return .none - case .개인정보_동의_버튼_클릭: + case .개인정보_동의_버튼_눌렀을때: state.webViewURL = Constants.개인정보_처리방침_주소 state.isWebViewPresented = true return .none - case .서비스_이용약관_버튼_클릭: + case .서비스_이용약관_버튼_눌렀을때: state.webViewURL = Constants.서비스_이용약관_주소 state.isWebViewPresented = true return .none - case .마케팅_정보_수신_버튼_클릭: + case .마케팅_정보_수신_버튼_눌렀을때: state.webViewURL = Constants.마케팅_정보_수신_주소 state.isWebViewPresented = true return .none @@ -123,19 +123,19 @@ private extension AgreeToTermsFeature { func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { /// - 개별 동의 체크 박스를 확인 하여 전체 동의 체크 여부 결정 - case .checkAgreements: + case .동의_체크_확인: let isAgreeAllterm = state.isPersonalAndUsageArgee && state.isServiceAgree && state.isMarketingAgree state.isAgreeAllTerms = isAgreeAllterm return .none /// - 각각의 개별 동의 체크박스가 선택 되었을 때 - case .personalAndUsageAgreeSelected, - .serviceAgreeSelected, - .marketingAgreeSelected: - return .send(.inner(.checkAgreements)) + case .개인정보_동의_선택했을때, + .서비스_이용약관_동의_선택했을때, + .마케팅_정보_수신_동의_선택했을때: + return .send(.inner(.동의_체크_확인)) /// - 전체 동의 체크박으가 선택 되었을 때 - case .allAgreementSelected: + case .전체_동의_선택했을때: state.isPersonalAndUsageArgee = state.isAgreeAllTerms state.isServiceAgree = state.isAgreeAllTerms state.isMarketingAgree = state.isAgreeAllTerms diff --git a/Projects/Feature/FeatureLogin/Sources/AgreeToTerms/AgreeToTermsView.swift b/Projects/Feature/FeatureLogin/Sources/AgreeToTerms/AgreeToTermsView.swift index 39e20bbb..3361dae5 100644 --- a/Projects/Feature/FeatureLogin/Sources/AgreeToTerms/AgreeToTermsView.swift +++ b/Projects/Feature/FeatureLogin/Sources/AgreeToTerms/AgreeToTermsView.swift @@ -40,7 +40,7 @@ public extension AgreeToTermsView { PokitBottomButton( "다음", state: store.isPersonalAndUsageArgee && store.isServiceAgree ? .filled(.primary) : .disable, - action: { send(.nextButtonTapped) } + action: { send(.다음_버튼_눌렀을때) } ) } .pokitMaxWidth() @@ -49,7 +49,7 @@ public extension AgreeToTermsView { PokitHeader { PokitHeaderItems(placement: .leading) { PokitToolbarButton(.icon(.arrowLeft)) { - send(.backButtonTapped) + send(.뒤로가기_버튼_눌렀을때) } } } @@ -98,19 +98,19 @@ extension AgreeToTermsView { termsButton( "(필수)개인정보 수집 및 이용 동의", isSelected: $store.isPersonalAndUsageArgee, - action: { send(.개인정보_동의_버튼_클릭) } + action: { send(.개인정보_동의_버튼_눌렀을때) } ) termsButton( "(필수)서비스 이용약관", isSelected: $store.isServiceAgree, - action: { send(.서비스_이용약관_버튼_클릭) } + action: { send(.서비스_이용약관_버튼_눌렀을때) } ) termsButton( "(선택)마케팅 정보 수신", isSelected: $store.isMarketingAgree, - action: { send(.마케팅_정보_수신_버튼_클릭) } + action: { send(.마케팅_정보_수신_버튼_눌렀을때) } ) } .padding(.leading, 20) diff --git a/Projects/App/Sources/Intro/IntroFeature.swift b/Projects/Feature/FeatureLogin/Sources/Intro/IntroFeature.swift similarity index 96% rename from Projects/App/Sources/Intro/IntroFeature.swift rename to Projects/Feature/FeatureLogin/Sources/Intro/IntroFeature.swift index e0f39160..584b66be 100644 --- a/Projects/App/Sources/Intro/IntroFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/Intro/IntroFeature.swift @@ -6,12 +6,11 @@ import ComposableArchitecture import CoreKit -import FeatureLogin @Reducer public struct IntroFeature { /// - Dependency - @Dependency(\.userDefaults) var userDefaults + @Dependency(UserDefaultsClient.self) var userDefaults /// - State @ObservableState public enum State { diff --git a/Projects/App/Sources/Intro/IntroView.swift b/Projects/Feature/FeatureLogin/Sources/Intro/IntroView.swift similarity index 100% rename from Projects/App/Sources/Intro/IntroView.swift rename to Projects/Feature/FeatureLogin/Sources/Intro/IntroView.swift diff --git a/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift b/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift index 990b656a..edc5e139 100644 --- a/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift @@ -11,21 +11,27 @@ import Util @Reducer public struct LoginFeature { /// - Dependency - @Dependency(\.dismiss) var dismiss - @Dependency(\.socialLogin) var socialLogin - @Dependency(\.authClient) var authClient - @Dependency(\.userClient) var userClient - @Dependency(\.userDefaults) var userDefaults - @Dependency(\.keychain) var keychain + @Dependency(\.dismiss) + var dismiss + @Dependency(SocialLoginClient.self) + var socialLogin + @Dependency(AuthClient.self) + var authClient + @Dependency(UserClient.self) + var userClient + @Dependency(UserDefaultsClient.self) + var userDefaults + @Dependency(KeychainClient.self) + var keychain /// - State @ObservableState public struct State { var path = StackState() - var nickName: String? - var interests: [String]? + var nickName: String? = nil + var interests: [String]? = nil - public init() {} + public init() { } } /// - Action public enum Action: FeatureAction, ViewAction { @@ -39,21 +45,22 @@ public struct LoginFeature { @CasePathable public enum View: Equatable { /// - Button Tapped - case appleLoginButtonTapped - case googleLoginButtonTapped + case 애플로그인_버튼_눌렀을때 + case 구글로그인_버튼_눌렀을때 } public enum InnerAction: Equatable { - case pushAgreeToTermsView - case pushRegisterNicknameView - case pushSelectFieldView(nickname: String) - case pushSignUpDoneView - case 애플로그인(SocialLoginInfo) - case 구글로그인(SocialLoginInfo) + case 약관동의_화면이동 + case 닉네임_등록_화면이동 + case 관심분야_선택_화면이동(nickname: String) + case 회원가입_완료_화면이동 case 로그인_이후_화면이동(isRegistered: Bool) } public enum AsyncAction: Equatable { - case 회원가입 - case 로그인(SocialLoginInfo) + case 회원가입_API + case 애플로그인_API(SocialLoginInfo) + case 구글로그인_API(SocialLoginInfo) + case 애플로그인_소셜_API + case 구글로그인_소셜_API } public enum ScopeAction { case agreeToTerms(AgreeToTermsFeature.Action.DelegateAction) @@ -100,34 +107,48 @@ private extension LoginFeature { /// - View Effect func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { - case .appleLoginButtonTapped: - return .run { send in - let response = try await socialLogin.appleLogin() - await send(.async(.로그인(response))) - } - - case .googleLoginButtonTapped: - return .run { send in - let response = try await socialLogin.googleLogin() - await send(.async(.로그인(response))) - } + case .애플로그인_버튼_눌렀을때: + return .send(.async(.애플로그인_소셜_API)) + case .구글로그인_버튼_눌렀을때: + return .send(.async(.구글로그인_소셜_API)) } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .pushAgreeToTermsView: + case .약관동의_화면이동: state.path.append(.agreeToTerms(AgreeToTermsFeature.State())) return .none - case .pushRegisterNicknameView: + case .닉네임_등록_화면이동: state.path.append(.registerNickname(RegisterNicknameFeature.State())) return .none - case .pushSelectFieldView(let nickname): + case .관심분야_선택_화면이동(let nickname): state.path.append(.selecteField(SelectFieldFeature.State(nickname: nickname))) return .none - case .pushSignUpDoneView: + case .회원가입_완료_화면이동: return .send(.delegate(.회원가입_완료_화면_이동)) - case let .애플로그인(response): + case let .로그인_이후_화면이동(isRegistered): + /// [3]. 이미 회원가입했던 유저라면 `메인`이동 + if isRegistered { + return .run { send in await send(.delegate(.dismissLoginRootView)) } + } else { + return .run { send in await send(.inner(.약관동의_화면이동)) } + } + } + } + /// - Async Effect + func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { + switch action { + case .회원가입_API: + return .run { [nickName = state.nickName, interests = state.interests] send in + guard let nickName else { return } + guard let interests else { return } + let signUpRequest = SignupRequest(nickName: nickName, interests: interests) + let _ = try await userClient.회원등록(signUpRequest) + + await send(.inner(.회원가입_완료_화면이동)) + } + case let .애플로그인_API(response): return .run { send in guard let idToken = response.idToken else { return } guard let authCode = response.authCode else { return } @@ -151,7 +172,7 @@ private extension LoginFeature { await send(.inner(.로그인_이후_화면이동(isRegistered: tokenResponse.isRegistered))) } - case let .구글로그인(response): + case let .구글로그인_API(response): return .run { send in guard let idToken = response.idToken else { return } let platform = response.provider.description @@ -167,34 +188,15 @@ private extension LoginFeature { await send(.inner(.로그인_이후_화면이동(isRegistered: tokenResponse.isRegistered))) } - case let .로그인_이후_화면이동(isRegistered): - /// [3]. 이미 회원가입했던 유저라면 `메인`이동 - if isRegistered { - return .run { send in await send(.delegate(.dismissLoginRootView)) } - } else { - return .run { send in await send(.inner(.pushAgreeToTermsView)) } - } - } - } - /// - Async Effect - func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { - switch action { - case .회원가입: - return .run { [nickName = state.nickName, interests = state.interests] send in - guard let nickName else { return } - guard let interests else { return } - let signUpRequest = SignupRequest(nickName: nickName, interests: interests) - let _ = try await userClient.회원등록(signUpRequest) - - await send(.inner(.pushSignUpDoneView)) + case .애플로그인_소셜_API: + return .run { send in + let response = try await socialLogin.appleLogin() + await send(.async(.애플로그인_API(response))) } - - case .로그인(let response): - switch response.provider { - case .apple: - return .run { send in await send(.inner(.애플로그인(response))) } - case .google: - return .run { send in await send(.inner(.구글로그인(response))) } + case .구글로그인_소셜_API: + return .run { send in + let response = try await socialLogin.googleLogin() + await send(.async(.구글로그인_API(response))) } } } @@ -204,19 +206,19 @@ private extension LoginFeature { case .agreeToTerms(let delegate): switch delegate { case .pushRegisterNicknameView: - return .send(.inner(.pushRegisterNicknameView)) + return .send(.inner(.닉네임_등록_화면이동)) } case .registerNickname(let delegate): switch delegate { case .pushSelectFieldView(let nickname): state.nickName = nickname - return .send(.inner(.pushSelectFieldView(nickname: nickname))) + return .send(.inner(.관심분야_선택_화면이동(nickname: nickname))) } case .selectField(let delegate): switch delegate { case let .pushSignUpDoneView(interests): state.interests = interests - return .send(.async(.회원가입)) + return .send(.async(.회원가입_API)) } } } diff --git a/Projects/Feature/FeatureLogin/Sources/Login/LoginView.swift b/Projects/Feature/FeatureLogin/Sources/Login/LoginView.swift index 17519224..7452a7e1 100644 --- a/Projects/Feature/FeatureLogin/Sources/Login/LoginView.swift +++ b/Projects/Feature/FeatureLogin/Sources/Login/LoginView.swift @@ -82,7 +82,7 @@ extension LoginView { private var appleLoginButton: some View { Button { - send(.appleLoginButtonTapped) + send(.애플로그인_버튼_눌렀을때) } label: { appleLoginButtonLabel } @@ -123,7 +123,7 @@ extension LoginView { private var googleLoginButton: some View { Button { - send(.googleLoginButtonTapped) + send(.구글로그인_버튼_눌렀을때) } label: { googleLoginButtonLabel } diff --git a/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameFeature.swift b/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameFeature.swift index c1f67009..ee5bf4df 100644 --- a/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameFeature.swift @@ -14,8 +14,8 @@ import Util public struct RegisterNicknameFeature { /// - Dependency @Dependency(\.dismiss) var dismiss - @Dependency(\.userClient) var userClient @Dependency(\.mainQueue) var mainQueue + @Dependency(UserClient.self) var userClient /// - State @ObservableState public struct State: Equatable { @@ -45,17 +45,19 @@ public struct RegisterNicknameFeature { /// - Binding case binding(BindingAction) /// - Button Tapped - case nextButtonTapped - case backButtonTapped + case 다음_버튼_눌렀을때 + case dismiss } + public enum InnerAction: Equatable { - case textChanged - case 닉네임_중복_체크_네트워크_결과(Bool) + case 닉네임_텍스트_변경되었을때 + case 닉네임_중복_체크_API_반영(Bool) } + public enum AsyncAction: Equatable { - case 닉네임_중복_체크_네트워크 + case 닉네임_중복_체크_API } - public enum ScopeAction: Equatable { case doNothing } + public enum ScopeAction: Equatable { case 없음 } public enum DelegateAction: Equatable { case pushSelectFieldView(nickname: String) } @@ -94,18 +96,14 @@ private extension RegisterNicknameFeature { /// - View Effect func handleViewAction(_ action: Action.ViewAction, state: inout State) -> Effect { switch action { - case .nextButtonTapped: - return .run { [nickName = state.nicknameText] send in - await send(.delegate(.pushSelectFieldView(nickname: nickName))) - } - case .backButtonTapped: + case .다음_버튼_눌렀을때: + let nickname = state.nicknameText + return .send(.delegate(.pushSelectFieldView(nickname: nickname))) + case .dismiss: return .run { _ in await self.dismiss() } case .binding(\.nicknameText): state.buttonActive = false - return .run { send in - await send(.inner(.textChanged)) - } - .debounce( + return .send(.inner(.닉네임_텍스트_변경되었을때)).debounce( id: CancelID.response, for: 0.5, scheduler: mainQueue @@ -117,7 +115,7 @@ private extension RegisterNicknameFeature { /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .textChanged: + case .닉네임_텍스트_변경되었을때: /// [1]. 닉네임 텍스트필드가 비어있을 때 if state.nicknameText.isEmpty { state.buttonActive = false @@ -136,10 +134,10 @@ private extension RegisterNicknameFeature { return .none } else { /// [4]. 정상 케이스일 때 - return .run { send in await send(.async(.닉네임_중복_체크_네트워크)) } + return .run { send in await send(.async(.닉네임_중복_체크_API)) } } - case let .닉네임_중복_체크_네트워크_결과(isDuplicate): + case let .닉네임_중복_체크_API_반영(isDuplicate): if isDuplicate { state.textfieldState = .error(message: "중복된 닉네임입니다.") state.buttonActive = false @@ -153,10 +151,10 @@ private extension RegisterNicknameFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .닉네임_중복_체크_네트워크: + case .닉네임_중복_체크_API: return .run { [nickName = state.nicknameText] send in let result = try await userClient.닉네임_중복_체크(nickName) - await send(.inner(.닉네임_중복_체크_네트워크_결과(result.isDuplicate))) + await send(.inner(.닉네임_중복_체크_API_반영(result.isDuplicate))) } } } diff --git a/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameView.swift b/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameView.swift index e321c920..83cf8c24 100644 --- a/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameView.swift +++ b/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameView.swift @@ -42,7 +42,7 @@ public extension RegisterNicknameView { state: store.buttonActive ? .filled(.primary) : .disable, - action: { send(.nextButtonTapped) } + action: { send(.다음_버튼_눌렀을때) } ) .setKeyboardHeight() } @@ -52,7 +52,7 @@ public extension RegisterNicknameView { PokitHeader { PokitHeaderItems(placement: .leading) { PokitToolbarButton(.icon(.arrowLeft)) { - send(.backButtonTapped) + send(.dismiss) } } } diff --git a/Projects/Feature/FeatureLogin/Sources/SelectField/SelectFieldFeature.swift b/Projects/Feature/FeatureLogin/Sources/SelectField/SelectFieldFeature.swift index 30bb71eb..38771fd6 100644 --- a/Projects/Feature/FeatureLogin/Sources/SelectField/SelectFieldFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/SelectField/SelectFieldFeature.swift @@ -13,7 +13,7 @@ import Util public struct SelectFieldFeature { /// - Dependency @Dependency(\.dismiss) var dismiss - @Dependency(\.userClient) var userClient + @Dependency(UserClient.self) var userClient /// - State @ObservableState public struct State: Equatable { @@ -48,8 +48,8 @@ public struct SelectFieldFeature { public enum InnerAction: Equatable { case 관심사_목록_조회_결과(interests: [InterestResponse]) } - public enum AsyncAction: Equatable { case doNothing } - public enum ScopeAction: Equatable { case doNothing } + public enum AsyncAction: Equatable { case 없음 } + public enum ScopeAction: Equatable { case 없음 } public enum DelegateAction: Equatable { case pushSignUpDoneView(interests: [String]) } diff --git a/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneFeature.swift b/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneFeature.swift index df3fa547..4a9a50ff 100644 --- a/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneFeature.swift @@ -35,16 +35,16 @@ public struct SignUpDoneFeature { @CasePathable public enum View: Equatable { /// - Button Tapped - case startButtonTapped + case 시작_버튼_눌렀을때 - case firecrackerOnAppeared - case titleOnAppeared - case confettiOnAppeared - case pookiOnAppeared + case 폭죽불꽃_이미지_나타났을때 + case 제목_나타났을때 + case 폭죽_이미지_나타났을때 + case 푸키_이미지_나타났을때 } - public enum InnerAction: Equatable { case doNothing } - public enum AsyncAction: Equatable { case doNothing } - public enum ScopeAction: Equatable { case doNothing } + public enum InnerAction: Equatable { case 없음 } + public enum AsyncAction: Equatable { case 없음 } + public enum ScopeAction: Equatable { case 없음 } public enum DelegateAction: Equatable { case dismissLoginRootView } @@ -81,18 +81,18 @@ private extension SignUpDoneFeature { /// - View Effect func handleViewAction(_ action: Action.ViewAction, state: inout State) -> Effect { switch action { - case .startButtonTapped: + case .시작_버튼_눌렀을때: return .send(.delegate(.dismissLoginRootView), animation: .pokitDissolve) - case .firecrackerOnAppeared: + case .폭죽불꽃_이미지_나타났을때: state.firecrackIsAppear = true return .none - case .titleOnAppeared: + case .제목_나타났을때: state.titleIsAppear = true return .none - case .confettiOnAppeared: + case .폭죽_이미지_나타났을때: state.confettiIsAppear = true return .none - case .pookiOnAppeared: + case .푸키_이미지_나타났을때: state.pookiIsAppear = true return .none } diff --git a/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneView.swift b/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneView.swift index d5f5c0de..018e4360 100644 --- a/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneView.swift +++ b/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneView.swift @@ -41,7 +41,7 @@ public extension SignUpDoneView { PokitBottomButton( "시작하기", state: .filled(.primary), - action: { send(.startButtonTapped) } + action: { send(.시작_버튼_눌렀을때) } ) .pokitMaxWidth() .padding(.horizontal, 20) @@ -65,7 +65,7 @@ extension SignUpDoneView { store.firecrackIsAppear ? 1 : 0, anchor: .bottomTrailing ) - .onAppear { send(.firecrackerOnAppeared, animation: .pokitSpring) } + .onAppear { send(.폭죽불꽃_이미지_나타났을때, animation: .pokitSpring) } Spacer() } @@ -83,7 +83,7 @@ extension SignUpDoneView { .multilineTextAlignment(.center) } .opacity(store.titleIsAppear ? 1 : 0) - .onAppear { send(.titleOnAppeared, animation: .pokitDissolve) } + .onAppear { send(.제목_나타났을때, animation: .pokitDissolve) } } private var images: some View { @@ -97,7 +97,7 @@ extension SignUpDoneView { store.confettiIsAppear ? 1 : 0, anchor: .bottom ) - .onAppear { send(.confettiOnAppeared, animation: .pokitSpring) } + .onAppear { send(.폭죽_이미지_나타났을때, animation: .pokitSpring) } Image(.character(.pooki)) .resizable() @@ -107,7 +107,7 @@ extension SignUpDoneView { store.pookiIsAppear ? 1 : 0, anchor: .bottom ) - .onAppear{ send(.pookiOnAppeared, animation: .pokitSpring) } + .onAppear{ send(.푸키_이미지_나타났을때, animation: .pokitSpring) } } } } diff --git a/Projects/App/Sources/Splash/SplashFeature.swift b/Projects/Feature/FeatureLogin/Sources/Splash/SplashFeature.swift similarity index 93% rename from Projects/App/Sources/Splash/SplashFeature.swift rename to Projects/Feature/FeatureLogin/Sources/Splash/SplashFeature.swift index da062e25..4d8b4acd 100644 --- a/Projects/App/Sources/Splash/SplashFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/Splash/SplashFeature.swift @@ -13,10 +13,14 @@ import Util @Reducer public struct SplashFeature { /// - Dependency - @Dependency(\.continuousClock) var clock - @Dependency(\.userDefaults) var userDefaults - @Dependency(\.authClient) var authClient - @Dependency(\.keychain) var keychain + @Dependency(\.continuousClock) + var clock + @Dependency(UserDefaultsClient.self) + var userDefaults + @Dependency(AuthClient.self) + var authClient + @Dependency(KeychainClient.self) + var keychain /// - State @ObservableState public struct State: Equatable { @@ -122,14 +126,14 @@ private extension SplashFeature { } /// 🚨 Error Case [1]: 로그인 했던 플랫폼 정보가 없을 때 guard let _ = userDefaults.stringKey(.authPlatform) else { - await send(.delegate(.loginNeeded)) + await send(.delegate(.loginNeeded), animation: .smooth) return } /// 🚨 Error Case [2]: refresh Token이 없을 때 guard let refreshToken = keychain.read(.refreshToken) else { keychain.delete(.accessToken) keychain.delete(.refreshToken) - await send(.delegate(.loginNeeded)) + await send(.delegate(.loginNeeded), animation: .smooth) return } @@ -139,9 +143,8 @@ private extension SplashFeature { keychain.save(.accessToken, tokenResponse.accessToken) await send(.delegate(.autoLoginSuccess)) } catch { - await send(.delegate(.loginNeeded)) + await send(.delegate(.loginNeeded), animation: .smooth) } - await send(.delegate(.loginNeeded)) } } } diff --git a/Projects/App/Sources/Splash/SplashView.swift b/Projects/Feature/FeatureLogin/Sources/Splash/SplashView.swift similarity index 100% rename from Projects/App/Sources/Splash/SplashView.swift rename to Projects/Feature/FeatureLogin/Sources/Splash/SplashView.swift diff --git a/Projects/Feature/FeatureLoginTests/Resources/info.plist b/Projects/Feature/FeatureLoginTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureLoginTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureLoginTests/Sources/FeatureLoginTests.swift b/Projects/Feature/FeatureLoginTests/Sources/FeatureLoginTests.swift new file mode 100644 index 00000000..0ead1e32 --- /dev/null +++ b/Projects/Feature/FeatureLoginTests/Sources/FeatureLoginTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureLogin + +final class FeatureLoginTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureMyFolderTests/Resources/info.plist b/Projects/Feature/FeatureMyFolderTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureMyFolderTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureMyFolderTests/Sources/FeatureMyFolderTests.swift b/Projects/Feature/FeatureMyFolderTests/Sources/FeatureMyFolderTests.swift new file mode 100644 index 00000000..96dd47e4 --- /dev/null +++ b/Projects/Feature/FeatureMyFolderTests/Sources/FeatureMyFolderTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureMyFolder + +final class FeatureMyFolderTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureMyPageTests/Resources/info.plist b/Projects/Feature/FeatureMyPageTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureMyPageTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureMyPageTests/Sources/FeatureMyPageTests.swift b/Projects/Feature/FeatureMyPageTests/Sources/FeatureMyPageTests.swift new file mode 100644 index 00000000..ff92c2db --- /dev/null +++ b/Projects/Feature/FeatureMyPageTests/Sources/FeatureMyPageTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureMyPage + +final class FeatureMyPageTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift b/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift index 429a9b83..87ec21cf 100644 --- a/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift +++ b/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift @@ -5,21 +5,20 @@ // Created by 김민호 on 7/16/24. import ComposableArchitecture +import FeatureContentCard import Domain import CoreKit import DSKit import Util -/// `unclassified`: 미분류 키워드 - @Reducer public struct PokitRootFeature { /// - Dependency - @Dependency(\.categoryClient) + @Dependency(CategoryClient.self) private var categoryClient - @Dependency(\.contentClient) + @Dependency(ContentClient.self) private var contentClient - @Dependency(\.kakaoShareClient) + @Dependency(KakaoShareClient.self) private var kakaoShareClient /// - State @ObservableState @@ -38,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? @@ -55,13 +45,10 @@ public struct PokitRootFeature { var isKebobSheetPresented: Bool = false var isPokitDeleteSheetPresented: Bool = false - var hasNext: Bool { - domain.categoryList.hasNext - } - - var unclassifiedHasNext: Bool { - domain.unclassifiedContentList.hasNext - } + + var hasNext: Bool { domain.categoryList.hasNext } + var unclassifiedHasNext: Bool { domain.unclassifiedContentList.hasNext } + var isLoading: Bool = true public init() { } } @@ -73,59 +60,56 @@ public struct PokitRootFeature { case async(AsyncAction) case scope(ScopeAction) case delegate(DelegateAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: BindableAction, Equatable { - /// - Binding case binding(BindingAction) - /// - Navigaiton Bar - case searchButtonTapped - case alertButtonTapped - case settingButtonTapped - /// - Filter - case filterButtonTapped(PokitRootFilterType.Folder) - case sortButtonTapped - /// - Kebob - case kebobButtonTapped(BaseCategoryItem) - case unclassifiedKebobButtonTapped(BaseContentItem) - - case categoryTapped(BaseCategoryItem) - case contentItemTapped(BaseContentItem) - - case 링크_공유_완료 - - case pokitRootViewOnAppeared - - case 다음페이지_로딩_presented + case 검색_버튼_눌렀을때 + case 알람_버튼_눌렀을때 + case 설정_버튼_눌렀을때 + case 필터_버튼_눌렀을때(PokitRootFilterType.Folder) + case 분류_버튼_눌렀을때 + case 케밥_버튼_눌렀을때(BaseCategoryItem) + case 미분류_케밥_버튼_눌렀을때(BaseContentItem) + case 카테고리_눌렀을때(BaseCategoryItem) + case 컨텐츠_항목_눌렀을때(BaseContentItem) + case 링크_공유_완료되었을때 + case 뷰가_나타났을때 + case 페이지_로딩중일때 } public enum InnerAction: Equatable { - case pokitCategorySheetPresented(Bool) - case pokitDeleteSheetPresented(Bool) case sort - case onAppearResult(classified: BaseCategoryListInquiry) - case 미분류_카테고리_컨텐츠_갱신(contentList: BaseContentListInquiry) - case 미분류_페이지네이션_결과(contentList: BaseContentListInquiry) - case 카테고리_갱신(categoryList: BaseCategoryListInquiry) - case 카테고리_페이지네이션_결과(contentList: BaseCategoryListInquiry) - case 컨텐츠_삭제(contentId: Int) + case 카테고리_시트_활성화(Bool) + case 카테고리_삭제_시트_활성화(Bool) + + case 미분류_카테고리_조회_API_반영(contentList: BaseContentListInquiry) + case 미분류_카테고리_페이징_조회_API_반영(contentList: BaseContentListInquiry) + case 미분류_카테고리_컨텐츠_삭제_API_반영(contentId: Int) + + case 카테고리_조회_API_반영(categoryList: BaseCategoryListInquiry) + case 카테고리_페이징_조회_API_반영(contentList: BaseCategoryListInquiry) + case 페이지네이션_초기화 } public enum AsyncAction: Equatable { - case 포킷삭제(categoryId: Int) - case 미분류_카테고리_컨텐츠_페이징_조회 - case 카테고리_페이징_조회 - case 미분류_카테고리_컨텐츠_조회 - case 카테고리_조회 - case 미분류_카테고리_컨텐츠_페이징_재조회 - case 카테고리_페이징_재조회 - case 미분류_카테고리_컨텐츠_삭제(contentId: Int) + case 카테고리_조회_API + case 카테고리_페이징_조회_API + case 카테고리_페이징_재조회_API + case 카테고리_삭제_API(categoryId: Int) + + case 미분류_카테고리_조회_API + case 미분류_카테고리_페이징_조회_API + case 미분류_카테고리_페이징_재조회_API + 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 { @@ -167,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))) } } @@ -174,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 @@ -181,73 +172,80 @@ private extension PokitRootFeature { /// - View Effect func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { - /// - Binding Action case .binding: return .none - /// - Navigation Bar Tapped Action - case .searchButtonTapped: + /// - Navigation Bar + case .검색_버튼_눌렀을때: return .run { send in await send(.delegate(.searchButtonTapped)) } - case .alertButtonTapped: + + case .알람_버튼_눌렀을때: return .run { send in await send(.delegate(.alertButtonTapped)) } - case .settingButtonTapped: + + case .설정_버튼_눌렀을때: return .run { send in await send(.delegate(.settingButtonTapped)) } - /// - Filter Action - /// 포킷 / 미분류 버튼 눌렀을 때 - case .filterButtonTapped(let selectedFolderType): + + case .필터_버튼_눌렀을때(let selectedFolderType): state.folderType = .folder(selectedFolderType) state.sortType = .sort(.최신순) return .send(.inner(.sort)) - /// 최신순 / 이름순 버튼 눌렀을 때 - case .sortButtonTapped: + + case .분류_버튼_눌렀을때: switch state.folderType { case .folder(.포킷): state.sortType = .sort(state.sortType == .sort(.이름순) ? .최신순 : .이름순) return .send(.inner(.sort), animation: .pokitDissolve) + case .folder(.미분류): state.sortType = .sort(state.sortType == .sort(.오래된순) ? .최신순 : .오래된순) return .send(.inner(.sort), animation: .pokitDissolve) + default: return .none } - /// - 케밥버튼 눌렀을 때 - /// 분류된 아이템의 케밥버튼 - case .kebobButtonTapped(let selectedItem): + + case .케밥_버튼_눌렀을때(let selectedItem): state.selectedKebobItem = selectedItem - return .run { send in await send(.inner(.pokitCategorySheetPresented(true))) } - /// 미분류 아이템의 케밥버튼 - case .unclassifiedKebobButtonTapped(let selectedItem): + return .run { send in await send(.inner(.카테고리_시트_활성화(true))) } + + case .미분류_케밥_버튼_눌렀을때(let selectedItem): state.selectedUnclassifiedItem = selectedItem - return .run { send in await send(.inner(.pokitCategorySheetPresented(true))) } + return .run { send in await send(.inner(.카테고리_시트_활성화(true))) } - /// - 카테고리 항목을 눌렀을 때 - case .categoryTapped(let category): + case .카테고리_눌렀을때(let category): return .run { send in await send(.delegate(.categoryTapped(category))) } /// - 링크 아이템을 눌렀을 때 - case .contentItemTapped(let selectedItem): + case .컨텐츠_항목_눌렀을때(let selectedItem): return .run { send in await send(.delegate(.contentDetailTapped(selectedItem))) } - case .pokitRootViewOnAppeared: + + case .뷰가_나타났을때: switch state.folderType { case .folder(.포킷): guard let _ = state.domain.categoryList.data?.count else { return .send(.inner(.페이지네이션_초기화)) } - return .send(.async(.카테고리_페이징_재조회), animation: .pokitSpring) + return .send(.async(.카테고리_페이징_재조회_API), animation: .pokitSpring) + case .folder(.미분류): guard let _ = state.domain.unclassifiedContentList.data?.count else { return .send(.inner(.페이지네이션_초기화)) } - return .send(.async(.미분류_카테고리_컨텐츠_페이징_재조회), animation: .pokitSpring) + return .send(.async(.미분류_카테고리_페이징_재조회_API), animation: .pokitSpring) + default: return .none } - case .다음페이지_로딩_presented: + + case .페이지_로딩중일때: switch state.folderType { case .folder(.포킷): - return .send(.async(.카테고리_페이징_조회)) + return .send(.async(.카테고리_페이징_조회_API)) + case .folder(.미분류): - return .send(.async(.미분류_카테고리_컨텐츠_페이징_조회)) + return .send(.async(.미분류_카테고리_페이징_조회_API)) + default: return .none } - case .링크_공유_완료: + + case .링크_공유_완료되었을때: state.shareSheetItem = nil return .none } @@ -256,40 +254,46 @@ private extension PokitRootFeature { /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case let .pokitCategorySheetPresented(presented): + case let .카테고리_시트_활성화(presented): state.isKebobSheetPresented = presented return .none - case let .pokitDeleteSheetPresented(presented): + case let .카테고리_삭제_시트_활성화(presented): state.isPokitDeleteSheetPresented = presented return .none - case let .onAppearResult(classified): - state.domain.categoryList = classified - return .none - case .sort: switch state.sortType { case .sort(.이름순): state.domain.pageable.sort = ["name,asc"] return .send(.inner(.페이지네이션_초기화), animation: .pokitDissolve) + case .sort(.오래된순): state.domain.pageable.sort = ["createdAt,asc"] return .send(.inner(.페이지네이션_초기화), animation: .pokitDissolve) + case .sort(.최신순): state.domain.pageable.sort = ["createdAt,desc"] return .send(.inner(.페이지네이션_초기화), animation: .pokitDissolve) + default: return .none } - case .미분류_카테고리_컨텐츠_갱신(contentList: let contentList): + 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 .카테고리_갱신(categoryList): + + case let .카테고리_조회_API_반영(categoryList): state.domain.categoryList = categoryList return .none - case let .카테고리_페이지네이션_결과(contentList): + case let .카테고리_페이징_조회_API_반영(contentList): let list = state.domain.categoryList.data ?? [] guard let newList = contentList.data else { return .none } @@ -298,30 +302,41 @@ private extension PokitRootFeature { state.domain.pageable.size = 10 return .none - case let .미분류_페이지네이션_결과(contentList): + case let .미분류_카테고리_페이징_조회_API_반영(contentList): let list = state.domain.unclassifiedContentList.data ?? [] guard let newList = contentList.data else { return .none } 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 .컨텐츠_삭제(contentId: contentId): + + case let .미분류_카테고리_컨텐츠_삭제_API_반영(contentId: contentId): guard let index = state.domain.unclassifiedContentList.data?.firstIndex(where: { $0.id == contentId }) else { return .none } state.domain.unclassifiedContentList.data?.remove(at: index) + state.contents.removeAll { $0.content.id == contentId } state.isPokitDeleteSheetPresented = false return .none + case .페이지네이션_초기화: 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(.포킷): - return .send(.async(.카테고리_조회), animation: .pokitDissolve) + return .send(.async(.카테고리_조회_API), animation: .pokitDissolve) + case .folder(.미분류): - return .send(.async(.미분류_카테고리_컨텐츠_조회), animation: .pokitDissolve) + return .send(.async(.미분류_카테고리_조회_API), animation: .pokitDissolve) + default: return .none } } @@ -330,72 +345,43 @@ private extension PokitRootFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case let .포킷삭제(categoryId): - return .run { send in - try await categoryClient.카테고리_삭제(categoryId) - } - case .미분류_카테고리_컨텐츠_페이징_조회: + case let .카테고리_삭제_API(categoryId): + return .run { _ in try await categoryClient.카테고리_삭제(categoryId) } + + case .미분류_카테고리_페이징_조회_API: state.domain.pageable.page += 1 - return .run { [ - pageable = state.domain.pageable - ] send in - let contentList = try await contentClient.미분류_카테고리_컨텐츠_조회( - .init( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ) - ).toDomain() - await send(.inner(.미분류_페이지네이션_결과(contentList: contentList))) + return .run { [pageable = state.domain.pageable] send in + let request = BasePageableRequest(page: pageable.page, size: pageable.size, sort: pageable.sort) + let contentList = try await contentClient.미분류_카테고리_컨텐츠_조회(request).toDomain() + await send(.inner(.미분류_카테고리_페이징_조회_API_반영(contentList: contentList))) } - case .카테고리_페이징_조회: + + case .카테고리_페이징_조회_API: state.domain.pageable.page += 1 - return .run { [ - pageable = state.domain.pageable - ] send in - let classified = try await categoryClient.카테고리_목록_조회( - .init( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ), - true - ).toDomain() - await send(.inner(.카테고리_페이지네이션_결과(contentList: classified))) + return .run { [pageable = state.domain.pageable] send in + let request = BasePageableRequest(page: pageable.page, size: pageable.size, sort: pageable.sort) + let classified = try await categoryClient.카테고리_목록_조회(request, true).toDomain() + await send(.inner(.카테고리_페이징_조회_API_반영(contentList: classified))) } - case .미분류_카테고리_컨텐츠_조회: + + case .미분류_카테고리_조회_API: state.domain.pageable.page = 0 - return .run { [ - pageable = state.domain.pageable - ] send in - let contentList = try await contentClient.미분류_카테고리_컨텐츠_조회( - .init( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ) - ).toDomain() - await send(.inner(.미분류_카테고리_컨텐츠_갱신(contentList: contentList)), animation: .pokitSpring) + return .run { [pageable = state.domain.pageable] send in + let request = BasePageableRequest(page: pageable.page, size: pageable.size, sort: pageable.sort) + let contentList = try await contentClient.미분류_카테고리_컨텐츠_조회(request).toDomain() + await send(.inner(.미분류_카테고리_조회_API_반영(contentList: contentList)), animation: .pokitSpring) } - case .카테고리_조회: + + case .카테고리_조회_API: state.domain.pageable.page = 0 - return .run { [ - pageable = state.domain.pageable - ] send in - let classified = try await categoryClient.카테고리_목록_조회( - .init( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ), - true - ).toDomain() - await send(.inner(.카테고리_갱신(categoryList: classified)), animation: .pokitSpring) + return .run { [pageable = state.domain.pageable] send in + let request = BasePageableRequest(page: pageable.page, size: pageable.size, sort: pageable.sort) + let classified = try await categoryClient.카테고리_목록_조회(request, true).toDomain() + await send(.inner(.카테고리_조회_API_반영(categoryList: classified)), animation: .pokitSpring) } - case .미분류_카테고리_컨텐츠_페이징_재조회: - return .run { [ - pageable = state.domain.pageable - ] send in + + case .미분류_카테고리_페이징_재조회_API: + return .run { [pageable = state.domain.pageable] send in let stream = AsyncThrowingStream { continuation in Task { for page in 0...pageable.page { @@ -420,14 +406,12 @@ private extension PokitRootFeature { } guard let contentItems else { return } await send( - .inner(.미분류_카테고리_컨텐츠_갱신(contentList: contentItems)), + .inner(.미분류_카테고리_조회_API_반영(contentList: contentItems)), animation: .pokitSpring ) } - case .카테고리_페이징_재조회: - return .run { [ - pageable = state.domain.pageable - ] send in + case .카테고리_페이징_재조회_API: + return .run { [pageable = state.domain.pageable] send in let stream = AsyncThrowingStream { continuation in Task { for page in 0...pageable.page { @@ -452,15 +436,13 @@ private extension PokitRootFeature { categoryItems?.data = items + newItems } guard let categoryItems else { return } - await send( - .inner(.카테고리_갱신(categoryList: categoryItems)), - animation: .pokitSpring - ) + await send(.inner(.카테고리_조회_API_반영(categoryList: categoryItems)), animation: .pokitSpring) } - case let .미분류_카테고리_컨텐츠_삭제(contentId): + + case let .미분류_카테고리_컨텐츠_삭제_API(contentId): return .run { send in let _ = try await contentClient.컨텐츠_삭제("\(contentId)") - await send(.inner(.컨텐츠_삭제(contentId: contentId)), animation: .pokitSpring) + await send(.inner(.미분류_카테고리_컨텐츠_삭제_API_반영(contentId: contentId)), animation: .pokitSpring) } } @@ -521,8 +503,8 @@ private extension PokitRootFeature { case .bottomSheet(.deleteCellButtonTapped): return .run { send in - await send(.inner(.pokitCategorySheetPresented(false))) - await send(.inner(.pokitDeleteSheetPresented(true))) + await send(.inner(.카테고리_시트_활성화(false))) + await send(.inner(.카테고리_삭제_시트_활성화(true))) } /// - Pokit Delete BottomSheet Delegate @@ -537,11 +519,7 @@ private extension PokitRootFeature { /// 🚨 Error Case [1]: 항목을 삭제하려는데 항목이 없을 때 return .none } - - return .send( - .async(.미분류_카테고리_컨텐츠_삭제(contentId: selectedItem.id)), - animation: .pokitSpring - ) + return .send(.async(.미분류_카테고리_컨텐츠_삭제_API(contentId: selectedItem.id)), animation: .pokitSpring) case .folder(.포킷): guard let selectedItem = state.selectedKebobItem else { @@ -554,9 +532,19 @@ private extension PokitRootFeature { state.domain.categoryList.data?.remove(at: index) state.isPokitDeleteSheetPresented = false - return .run { send in await send(.async(.포킷삭제(categoryId: selectedItem.id))) } + return .run { send in await send(.async(.카테고리_삭제_API(categoryId: selectedItem.id))) } + 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 } } @@ -567,9 +555,11 @@ private extension PokitRootFeature { case .미분류_카테고리_컨텐츠_조회: switch state.folderType { case .folder(.포킷): - return .send(.async(.카테고리_페이징_재조회), animation: .pokitSpring) + return .send(.async(.카테고리_페이징_재조회_API), animation: .pokitSpring) + case .folder(.미분류): - return .send(.async(.미분류_카테고리_컨텐츠_페이징_재조회), animation: .pokitSpring) + return .send(.async(.미분류_카테고리_페이징_재조회_API), animation: .pokitSpring) + default: return .none } default: diff --git a/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift b/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift index b8525b4d..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 @@ -49,7 +50,7 @@ public extension PokitRootView { if let shareURL = URL(string: content.data) { PokitShareSheet( items: [shareURL], - completion: { send(.링크_공유_완료) } + completion: { send(.링크_공유_완료되었을때) } ) .presentationDetents([.medium, .large]) } @@ -62,7 +63,7 @@ public extension PokitRootView { delegateSend: { store.send(.scope(.deleteBottomSheet($0)), animation: .pokitSpring) } ) } - .task { await send(.pokitRootViewOnAppeared).finish() } + .task { await send(.뷰가_나타났을때).finish() } } } } @@ -78,7 +79,7 @@ private extension PokitRootView { : .default(.secondary), size: .small, shape: .round, - action: { send(.filterButtonTapped(.포킷)) } + action: { send(.필터_버튼_눌렀을때(.포킷)) } ) PokitIconLButton( @@ -89,7 +90,7 @@ private extension PokitRootView { : .default(.secondary), size: .small, shape: .round, - action: { send(.filterButtonTapped(.미분류)) } + action: { send(.필터_버튼_눌렀을때(.미분류)) } ) Spacer() @@ -98,7 +99,7 @@ private extension PokitRootView { store.sortType == .sort(.최신순) ? "최신순" : store.folderType == .folder(.포킷) ? "이름순" : "오래된순", icon: .icon(.align), - action: { send(.sortButtonTapped) } + action: { send(.분류_버튼_눌렀을때) } ) .contentTransition(.numericText()) } @@ -149,15 +150,15 @@ private extension PokitRootView { ForEach(categories, id: \.id) { item in PokitCard( category: item, - action: { send(.categoryTapped(item)) }, - kebabAction: { send(.kebobButtonTapped(item)) } + action: { send(.카테고리_눌렀을때(item)) }, + kebabAction: { send(.케밥_버튼_눌렀을때(item)) } ) } } if store.hasNext { PokitLoading() - .onAppear { send(.다음페이지_로딩_presented) } + .onAppear { send(.페이지_로딩중일때) } } } .padding(.bottom, 150) @@ -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,25 +188,25 @@ 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(.contentItemTapped(content)) }, - kebabAction: { send(.unclassifiedKebobButtonTapped(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 { PokitLoading() - .onAppear(perform: { send(.다음페이지_로딩_presented) }) + .onAppear(perform: { send(.페이지_로딩중일때) }) } } .padding(.bottom, 150) diff --git a/Projects/Feature/FeaturePokitTests/Resources/info.plist b/Projects/Feature/FeaturePokitTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeaturePokitTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeaturePokitTests/Sources/FeaturePokitTests.swift b/Projects/Feature/FeaturePokitTests/Sources/FeaturePokitTests.swift new file mode 100644 index 00000000..6ddbeaaa --- /dev/null +++ b/Projects/Feature/FeaturePokitTests/Sources/FeaturePokitTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeaturePokit + +final class FeaturePokitTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift b/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift index 72f34f6f..24766a92 100644 --- a/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift +++ b/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift @@ -4,6 +4,8 @@ // // Created by 김도형 on 7/12/24. +import SwiftUI + import ComposableArchitecture import Domain import CoreKit @@ -15,11 +17,12 @@ public struct RemindFeature { /// - Dependency @Dependency(\.dismiss) private var dismiss - @Dependency(\.remindClient) + @Dependency(RemindClient.self) private var remindClient - @Dependency(\.contentClient) + @Dependency(ContentClient.self) private var contentClient - + @Dependency(SwiftSoupClient.self) + private var swiftSoupClient /// - State @ObservableState public struct State: Equatable { @@ -65,35 +68,46 @@ public struct RemindFeature { public enum View: Equatable, BindableAction { case binding(BindingAction) - /// - Button Tapped - case bellButtonTapped - case searchButtonTapped - case linkCardTapped(content: BaseContentItem) - case kebabButtonTapped(content: BaseContentItem) - case unreadNavigationLinkTapped - case favoriteNavigationLinkTapped - case bottomSheetButtonTapped( + case bottomSheet( delegate: PokitBottomSheet.Delegate, content: BaseContentItem ) - case deleteAlertConfirmTapped(content: BaseContentItem) + + /// - Button Tapped + case 알림_버튼_눌렀을때 + case 검색_버튼_눌렀을때 + case 컨텐츠_항목_눌렀을때(content: BaseContentItem) + case 컨텐츠_항목_케밥_버튼_눌렀을때(content: BaseContentItem) + case 안읽음_목록_버튼_눌렀을때 + case 즐겨찾기_목록_버튼_눌렀을때 + case 링크_삭제_눌렀을때(content: BaseContentItem) case 링크_공유_완료 - case remindViewOnAppeared + case 뷰가_나타났을때 + case 즐겨찾기_항목_이미지_조회(contentId: Int) + case 읽지않음_항목_이미지_조회(contentId: Int) + case 리마인드_항목_이미지오류_나타났을때(contentId: Int) } public enum InnerAction: Equatable { - case dismissBottomSheet - case 오늘의_리마인드_조회(contents: [BaseContentItem]) - case 읽지않음_컨텐츠_조회(contentList: BaseContentListInquiry) - case 즐겨찾기_링크모음_조회(contentList: BaseContentListInquiry) - case 컨텐츠_삭제_반영(id: Int) + 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 오늘의_리마인드_조회 - case 읽지않음_컨텐츠_조회 - case 즐겨찾기_링크모음_조회 - case 컨텐츠_삭제(id: Int) + 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( @@ -108,7 +122,7 @@ public struct RemindFeature { case 링크수정(id: Int) case 링크목록_안읽음 case 링크목록_즐겨찾기 - case 컨텐츠목록_조회 + case 컨텐츠_상세보기_delegate_위임 } } /// initiallizer @@ -144,74 +158,92 @@ private extension RemindFeature { /// - View Effect func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { - case .bellButtonTapped: - return .run { send in await send(.delegate(.alertButtonTapped)) } - case .searchButtonTapped: - return .run { send in await send(.delegate(.searchButtonTapped)) } - case .favoriteNavigationLinkTapped: + case .binding: + return .none + case .bottomSheet(let delegate, let content): + return .run { send in + await send(.inner(.바텀시트_해제)) + await send(.scope(.bottomSheet(delegate: delegate, content: content))) + } + case .알림_버튼_눌렀을때: + return .send(.delegate(.alertButtonTapped)) + case .검색_버튼_눌렀을때: + return .send(.delegate(.searchButtonTapped)) + case .즐겨찾기_목록_버튼_눌렀을때: return .send(.delegate(.링크목록_즐겨찾기)) - case .unreadNavigationLinkTapped: + case .안읽음_목록_버튼_눌렀을때: return .send(.delegate(.링크목록_안읽음)) - case .kebabButtonTapped(let content): + case .컨텐츠_항목_케밥_버튼_눌렀을때(let content): state.bottomSheetItem = content return .none - case .linkCardTapped(let content): + case .컨텐츠_항목_눌렀을때(let content): return .send(.delegate(.링크상세(content: content))) - case .bottomSheetButtonTapped(let delegate, let content): - return .run { send in - await send(.inner(.dismissBottomSheet)) - await send(.scope(.bottomSheet(delegate: delegate, content: content))) - } - case .deleteAlertConfirmTapped: + case .링크_삭제_눌렀을때: guard let id = state.alertItem?.id else { return .none } - return .run { [id] send in - await send(.async(.컨텐츠_삭제(id: id))) - } - case .binding: - return .none - case .remindViewOnAppeared: - return .run { send in - await send(.async(.오늘의_리마인드_조회), animation: .pokitDissolve) - await send(.async(.읽지않음_컨텐츠_조회), animation: .pokitDissolve) - await send(.async(.즐겨찾기_링크모음_조회), animation: .pokitDissolve) - } + return .send(.async(.컨텐츠_삭제_API(id: id))) + case .뷰가_나타났을때: + return allContentFetch(animation: .pokitDissolve) 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 func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .dismissBottomSheet: + case .바텀시트_해제: state.bottomSheetItem = nil return .none - case .오늘의_리마인드_조회(contents: let contents): + case .오늘의_리마인드_조회_API_반영(contents: let contents): state.domain.recommendedList = contents return .none - case .읽지않음_컨텐츠_조회(contentList: let contentList): + case .읽지않음_컨텐츠_조회_API_반영(contentList: let contentList): state.domain.unreadList = contentList return .none - case .즐겨찾기_링크모음_조회(contentList: let contentList): + case .즐겨찾기_링크모음_조회_API_반영(contentList: let contentList): state.domain.favoriteList = contentList return .none - case .컨텐츠_삭제_반영(id: let contentId): + case .컨텐츠_삭제_API_반영(id: let contentId): state.alertItem = nil state.domain.recommendedList?.removeAll { $0.id == contentId } 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 func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .오늘의_리마인드_조회: + case .오늘의_리마인드_조회_API: return .run { send in let contents = try await remindClient.오늘의_리마인드_조회().map { $0.toDomain() } - await send(.inner(.오늘의_리마인드_조회(contents: contents)), animation: .pokitDissolve) + await send(.inner(.오늘의_리마인드_조회_API_반영(contents: contents)), animation: .pokitDissolve) } - case .읽지않음_컨텐츠_조회: + case .읽지않음_컨텐츠_조회_API: return .run { [pageable = state.domain.unreadListPageable] send in let contentList = try await remindClient.읽지않음_컨텐츠_조회( BasePageableRequest( @@ -220,9 +252,9 @@ private extension RemindFeature { sort: pageable.sort ) ).toDomain() - await send(.inner(.읽지않음_컨텐츠_조회(contentList: contentList)), animation: .pokitDissolve) + await send(.inner(.읽지않음_컨텐츠_조회_API_반영(contentList: contentList)), animation: .pokitDissolve) } - case .즐겨찾기_링크모음_조회: + case .즐겨찾기_링크모음_조회_API: return .run { [pageable = state.domain.favoriteListPageable] send in let contentList = try await remindClient.즐겨찾기_링크모음_조회( BasePageableRequest( @@ -231,12 +263,58 @@ private extension RemindFeature { sort: pageable.sort ) ).toDomain() - await send(.inner(.즐겨찾기_링크모음_조회(contentList: contentList)), animation: .pokitDissolve) + await send(.inner(.즐겨찾기_링크모음_조회_API_반영(contentList: contentList)), animation: .pokitDissolve) } - case .컨텐츠_삭제(id: let id): - return .run { [id] send in + case .컨텐츠_삭제_API(id: let id): + return .run { send in let _ = try await contentClient.컨텐츠_삭제("\(id)") - await send(.inner(.컨텐츠_삭제_반영(id: id)), animation: .pokitSpring) + 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 + ))) } } } @@ -262,13 +340,17 @@ private extension RemindFeature { /// - Delegate Effect func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { switch action { - case .컨텐츠목록_조회: - return .run { send in - await send(.async(.오늘의_리마인드_조회)) - await send(.async(.읽지않음_컨텐츠_조회)) - await send(.async(.즐겨찾기_링크모음_조회)) - } + 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 index 6f78327d..f4630aa5 100644 --- a/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift +++ b/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift @@ -35,7 +35,7 @@ public extension RemindView { PokitBottomSheet( items: [.share, .edit, .delete], height: 224 - ) { send(.bottomSheetButtonTapped(delegate: $0, content: content)) } + ) { send(.bottomSheet(delegate: $0, content: content)) } } .sheet(item: $store.shareSheetItem) { content in if let shareURL = URL(string: content.data) { @@ -51,9 +51,9 @@ public extension RemindView { "링크를 정말 삭제하시겠습니까?", message: "함께 저장한 모든 정보가 삭제되며, \n복구하실 수 없습니다.", confirmText: "삭제" - ) { send(.deleteAlertConfirmTapped(content: content)) } + ) { send(.링크_삭제_눌렀을때(content: content)) } } - .task { await send(.remindViewOnAppeared, animation: .pokitDissolve).finish() } + .task { await send(.뷰가_나타났을때, animation: .pokitDissolve).finish() } } } } @@ -134,28 +134,18 @@ extension RemindView { @ViewBuilder private func recommendedContentCell(content: BaseContentItem) -> some View { - Button(action: { send(.linkCardTapped(content: content)) }) { + Button(action: { send(.컨텐츠_항목_눌렀을때(content: content)) }) { recommendedContentCellLabel(content: content) } - } @ViewBuilder private func recommendedContentCellLabel(content: BaseContentItem) -> some View { ZStack(alignment: .bottom) { - LazyImage(url: .init(string: content.thumbNail)) { phase in - if let image = phase.image { - image - .resizable() - } else { - ZStack { - Color.pokit(.bg(.disable)) - - PokitSpinner() - .foregroundStyle(.pokit(.icon(.brand))) - .frame(width: 48, height: 48) - } - } + if let url = URL(string: content.thumbNail) { + recommendedContentCellImage(url: url, contentId: content.id) + } else { + imagePlaceholder } LinearGradient( @@ -186,7 +176,7 @@ extension RemindView { Spacer() kebabButton { - send(.kebabButtonTapped(content: content)) + send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) } .foregroundStyle(.pokit(.icon(.inverseWh))) .zIndex(1) @@ -203,6 +193,33 @@ extension RemindView { } .frame(width: 216, height: 194) .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 @@ -243,7 +260,7 @@ extension RemindView { VStack(spacing: 0) { VStack(spacing: 0) { listNavigationLink("한번도 읽지 않았어요") { - send(.unreadNavigationLinkTapped) + send(.안읽음_목록_버튼_눌렀을때) } .padding(.bottom, 16) } @@ -254,8 +271,9 @@ extension RemindView { PokitLinkCard( link: content, - action: { send(.linkCardTapped(content: content)) }, - kebabAction: { send(.kebabButtonTapped(content: content)) } + action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, + kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) }, + fetchMetaData: { send(.읽지않음_항목_이미지_조회(contentId: content.id)) } ) .divider(isFirst: isFirst, isLast: isLast) } @@ -270,7 +288,7 @@ extension RemindView { ) -> some View { VStack(spacing: 0) { listNavigationLink("즐겨찾기 링크만 모았어요") { - send(.favoriteNavigationLinkTapped) + send(.즐겨찾기_목록_버튼_눌렀을때) } .padding(.bottom, 16) @@ -288,8 +306,9 @@ extension RemindView { PokitLinkCard( link: content, - action: { send(.linkCardTapped(content: content)) }, - kebabAction: { send(.kebabButtonTapped(content: content)) } + action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, + kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) }, + fetchMetaData: { send(.즐겨찾기_항목_이미지_조회(contentId: content.id)) } ) .divider(isFirst: isFirst, isLast: isLast) } diff --git a/Projects/Feature/FeatureRemindTests/Resources/info.plist b/Projects/Feature/FeatureRemindTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureRemindTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureRemindTests/Sources/FeatureRemindTests.swift b/Projects/Feature/FeatureRemindTests/Sources/FeatureRemindTests.swift new file mode 100644 index 00000000..3690595d --- /dev/null +++ b/Projects/Feature/FeatureRemindTests/Sources/FeatureRemindTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureRemind + +final class FeatureRemindTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxFeature.swift b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxFeature.swift index 232aa4a3..ff647e64 100644 --- a/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxFeature.swift @@ -14,20 +14,21 @@ import CoreKit @Reducer public struct PokitAlertBoxFeature { /// - Dependency - @Dependency(\.dismiss) var dismiss - @Dependency(\.pasteboard) var pasteboard - @Dependency(\.alertClient) var alertClient + @Dependency(\.dismiss) + var dismiss + @Dependency(PasteboardClient.self) + var pasteboard + @Dependency(AlertClient.self) + var alertClient /// - State @ObservableState public struct State: Equatable { public init() {} - + fileprivate var domain = Alert() var alertContents: IdentifiedArrayOf? { - guard let list = domain.alertList.data else { - return nil - } + guard let list = domain.alertList.data else { return nil } var identifiedArray = IdentifiedArrayOf() list.forEach { identifiedArray.append($0) } return identifiedArray @@ -44,22 +45,27 @@ public struct PokitAlertBoxFeature { @CasePathable public enum View: Equatable { - case deleteSwiped(item: AlertItem) - case itemSelected(item: AlertItem) case dismiss - case onAppear case pagenation + case 밀어서_삭제했을때(item: AlertItem) + case 알람_항목_선택했을때(item: AlertItem) + case 뷰가_나타났을때 } public enum InnerAction: Equatable { - case onAppearResult(AlertListInquiry) - case pagenation_result(AlertListInquiry) - case 삭제결과(item: AlertItem) + case pagenation_알람_목록_조회_API_반영(AlertListInquiry) + case 뷰가_나타났을때_알람_목록_조회_API_반영(AlertListInquiry) + case 알람_삭제_API_반영(item: AlertItem) } - public enum AsyncAction: Equatable { case doNothing } + public enum AsyncAction: Equatable { + case pagenation_알람_목록_조회_API + case 뷰가_나타났을때_알람_목록_조회_API + case 알람_삭제_API(item: AlertItem) + case 클립보드_감지 + } - public enum ScopeAction: Equatable { case doNothing } + public enum ScopeAction: Equatable { case 없음 } public enum DelegateAction: Equatable { case moveToContentEdit(id: Int) @@ -70,8 +76,6 @@ public struct PokitAlertBoxFeature { /// - Initiallizer public init() {} - - public enum CancelID { case disAppear } /// - Reducer Core private func core(into state: inout State, action: Action) -> Effect { @@ -79,19 +83,15 @@ public struct PokitAlertBoxFeature { /// - 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) @@ -108,57 +108,35 @@ private extension PokitAlertBoxFeature { /// - View Effect func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { - /// - 스와이프를 통해 아이템 삭제를 눌렀을 때 - case .deleteSwiped(let item): - return .run { send in - try await alertClient.알람_삭제("\(item.id)") - await send(.inner(.삭제결과(item: item))) - } - /// - 선택한 항목을 `링크수정`화면으로 이동해 수정 - case .itemSelected(let item): - return .run { send in await send(.delegate(.moveToContentEdit(id: item.contentId))) } case .dismiss: - return .run { send in -// await dismiss() - await send(.delegate(.alertBoxDismiss)) - } - case .onAppear: - return .run { [domain = state.domain.alertList] send in - let sort: [String] = ["createdAt", "desc"] - let request = BasePageableRequest(page: 0, size: domain.size, sort: sort) - let result = try await alertClient.알람_목록_조회(request).toDomain() - await send(.inner(.onAppearResult(result))) - - for await _ in self.pasteboard.changes() { - let url = try await pasteboard.probableWebURL() - await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) - } - } + return .send(.delegate(.alertBoxDismiss)) + + case let .밀어서_삭제했을때(item): + return .send(.async(.알람_삭제_API(item: item))) + + case let .알람_항목_선택했을때(item): + return .send(.delegate(.moveToContentEdit(id: item.contentId))) + + case .뷰가_나타났을때: + return .merge( + .send(.async(.뷰가_나타났을때_알람_목록_조회_API)), + .send(.async(.클립보드_감지)) + ) + case .pagenation: - if state.domain.alertList.hasNext { - return .run { [domain = state.domain.alertList] send in - let sort: [String] = ["createdAt", "desc"] - let request = BasePageableRequest( - page: domain.page + 1, - size: 10, - sort: sort - ) - let result = try await alertClient.알람_목록_조회(request).toDomain() - await send(.inner(.pagenation_result(result))) - } - } - return .none + return state.domain.alertList.hasNext + ? .send(.async(.pagenation_알람_목록_조회_API)) + : .none } } - /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case let .onAppearResult(list): + case let .뷰가_나타났을때_알람_목록_조회_API_반영(list): state.domain.alertList = list return .none - case let .pagenation_result(alertList): + case let .pagenation_알람_목록_조회_API_반영(alertList): guard var list = state.domain.alertList.data else { return .none } guard let newList = alertList.data else { return .none } @@ -167,24 +145,54 @@ private extension PokitAlertBoxFeature { state.domain.alertList.data = list return .none - case let .삭제결과(item): - //TODO: 삭제연결 + case let .알람_삭제_API_반영(item): guard let idx = state.domain.alertList.data?.firstIndex(where: { $0 == item }) else { return .none } state.domain.alertList.data?.remove(at: idx) return .none } } - /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { - return .none + switch action { + case .pagenation_알람_목록_조회_API: + return .run { [domain = state.domain.alertList] send in + let sort: [String] = ["createdAt", "desc"] + let request = BasePageableRequest( + page: domain.page + 1, + size: 10, + sort: sort + ) + let result = try await alertClient.알람_목록_조회(request).toDomain() + await send(.inner(.pagenation_알람_목록_조회_API_반영(result))) + } + + case .뷰가_나타났을때_알람_목록_조회_API: + return .run { [domain = state.domain.alertList] send in + let sort: [String] = ["createdAt", "desc"] + let request = BasePageableRequest(page: 0, size: domain.size, sort: sort) + let result = try await alertClient.알람_목록_조회(request).toDomain() + await send(.inner(.뷰가_나타났을때_알람_목록_조회_API_반영(result))) + } + + case let .알람_삭제_API(item): + return .run { send in + try await alertClient.알람_삭제("\(item.id)") + await send(.inner(.알람_삭제_API_반영(item: item))) + } + + case .클립보드_감지: + return .run { send in + for await _ in self.pasteboard.changes() { + let url = try await pasteboard.probableWebURL() + await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) + } + } + } } - /// - 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/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift index 505d2a96..c7fff957 100644 --- a/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift +++ b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift @@ -31,7 +31,7 @@ public extension PokitAlertBoxView { VStack { PokitCaution( image: .pooki, - titleKey: "알람이 없어요", + titleKey: "알림이 없어요", message: "리마인드 알림을 설정하세요" ) .padding(.top, 84) @@ -40,7 +40,7 @@ public extension PokitAlertBoxView { } else { List { ForEach(alertContents, id: \.id) { item in - Button(action: { send(.itemSelected(item: item)) }) { + Button(action: { send(.알람_항목_선택했을때(item: item)) }) { AlertContent(item: item) } .listRowSeparator(.hidden) @@ -58,7 +58,7 @@ public extension PokitAlertBoxView { } .pokitNavigationBar { navigationBar } .ignoresSafeArea(edges: .bottom) - .task { await send(.onAppear).finish() } + .task { await send(.뷰가_나타났을때).finish() } } } } @@ -76,7 +76,7 @@ private extension PokitAlertBoxView { } func delete(_ item: AlertItem) { - send(.deleteSwiped(item: item),animation: .pokitSpring) + send(.밀어서_삭제했을때(item: item),animation: .pokitSpring) } struct AlertContent: View { diff --git a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift index c66315be..9664bf83 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 @@ -19,11 +20,11 @@ public struct PokitSearchFeature { private var dismiss @Dependency(\.mainQueue) private var mainQueue - @Dependency(\.pasteboard) + @Dependency(PasteboardClient.self) private var pasteboard - @Dependency(\.userDefaults) + @Dependency(UserDefaultsClient.self) private var userDefaults - @Dependency(\.contentClient) + @Dependency(ContentClient.self) private var contentClient /// - State @ObservableState @@ -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 } @@ -70,16 +64,14 @@ public struct PokitSearchFeature { set { domain.condition.endDate = newValue } } var startDateString: String? { - guard let startDate = domain.condition.startDate else { - return nil - } + guard let startDate = domain.condition.startDate else { return nil} let formatter = DateFormat.searchCondition.formatter - return formatter.string(from: startDate) } var hasNext: Bool { get { domain.contentList.hasNext } } + var isLoading: Bool = false /// sheet item var bottomSheetItem: BaseContentItem? = nil @@ -95,75 +87,72 @@ public struct PokitSearchFeature { case scope(ScopeAction) case delegate(DelegateAction) case fiterBottomSheet(PresentationAction) + case contents(IdentifiedActionOf) @CasePathable public enum View: Equatable, BindableAction { - /// - Binding case binding(BindingAction) - /// - Button Tapped - case autoSaveButtonTapped - case searchTextInputIconTapped - case searchTextChipButtonTapped(text: String) - case filterButtonTapped - case contentTypeFilterButtonTapped - case favoriteChipTapped - case unreadChipTapped - case dateFilterButtonTapped - case categoryFilterButtonTapped - case categoryFilterChipTapped(category: BaseCategoryItem) - case recentSearchAllRemoveButtonTapped - case recentSearchChipIconTapped(searchText: String) - case linkCardTapped(content: BaseContentItem) - case kebabButtonTapped(content: BaseContentItem) - case bottomSheetButtonTapped( + case dismiss + case bottomSheet( delegate: PokitBottomSheet.Delegate, content: BaseContentItem ) - case deleteAlertConfirmTapped - case sortTextLinkTapped - case backButtonTapped - /// - TextInput OnSubmitted - case searchTextInputOnSubmitted - - case 링크_공유_완료 - - case onAppear - case 로딩_isPresented + case 자동저장_버튼_눌렀을때 + case 검색_버튼_눌렀을때 + case 최근검색_태그_눌렀을때(text: String) + case 최근검색_태그_삭제_눌렀을때(searchText: String) + case 필터링_버튼_눌렀을때 + case 카테고리_버튼_눌렀을때 + case 모아보기_버튼_눌렀을때 + case 기간_버튼_눌렀을때 + case 카테고리_태그_눌렀을때(category: BaseCategoryItem) + case 즐겨찾기_태그_눌렀을때 + case 안읽음_태그_눌렀을때 + case 전체_삭제_버튼_눌렀을때 + case 컨텐츠_항목_눌렀을때(content: BaseContentItem) + case 컨텐츠_항목_케밥_버튼_눌렀을때(content: BaseContentItem) + case 정렬_버튼_눌렀을때 + case 검색_키보드_엔터_눌렀을때 + case 링크_삭제_눌렀을때 + case 링크_공유_완료되었을때 + case 뷰가_나타났을때 + case 로딩중일때 } public enum InnerAction: Equatable { - case enableIsSearching - case disableIsSearching - case updateDateFilter(startDate: Date?, endDate: Date?) - case showFilterBottomSheet(filterType: FilterBottomFeature.FilterType) - case updateContentTypeFilter(favoriteFilter: Bool, unreadFilter: Bool) - case dismissBottomSheet - case updateIsFiltered - case updateCategoryIds - case 컨텐츠_목록_갱신(BaseContentListInquiry) + case filterBottomSheet(filterType: FilterBottomFeature.FilterType) + case 검색창_활성화(Bool) + case 기간_업데이트(startDate: Date?, endDate: Date?) + case 모아보기_업데이트(favoriteFilter: Bool, unreadFilter: Bool) + case 필터_업데이트 + case 카테고리_ID_목록_업데이트 + case 바텀시트_해제 + case 컨텐츠_검색_API_반영(BaseContentListInquiry) + case 컨텐츠_검색_페이징_API_반영(BaseContentListInquiry) + case 컨텐츠_삭제_API_반영(id: Int) case 최근검색어_불러오기 - case 자동저장_켜기_불러오기 + case 자동저장_불러오기 case 최근검색어_추가 - case 컨텐츠_삭제_반영(id: Int) - case 컨텐츠_검색_결과_페이징_갱신(BaseContentListInquiry) case 페이징_초기화 } public enum AsyncAction: Equatable { - case 컨텐츠_검색 - case 최근검색어_갱신 - case 자동저장_켜기_갱신 - case 컨텐츠_삭제(id: Int) - case 컨텐츠_검색_결과_페이징_조회 + case 컨텐츠_검색_API + case 최근검색어_갱신_수행 + case 자동저장_수행 + case 컨텐츠_삭제_API(id: Int) + case 컨텐츠_검색_페이징_API + 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 { @@ -183,26 +172,27 @@ public struct PokitSearchFeature { /// - 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 .fiterBottomSheet(.presented(.delegate(let delegate))): return .send(.scope(.filterBottomSheet(delegate))) + case .fiterBottomSheet: return .none + + case .contents(let contentsAction): + return .send(.scope(.contents(contentsAction))) } } public enum CancelID { case response } @@ -210,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() } @@ -223,152 +216,167 @@ private extension PokitSearchFeature { switch action { case .binding(\.searchText): guard !state.searchText.isEmpty else { - /// 🚨 Error Case [1]: 빈 문자열 일 때 - return .send(.inner(.disableIsSearching)) + /// 🚨 Error Case: 빈 문자열 일 때 + return .send(.inner(.검색창_활성화(false))) } return .none + case .binding: return .none - case .autoSaveButtonTapped: + + case .자동저장_버튼_눌렀을때: state.isAutoSaveSearch.toggle() - return .send(.async(.자동저장_켜기_갱신)) - case .searchTextInputOnSubmitted: + return .send(.async(.자동저장_수행)) + + case .검색_키보드_엔터_눌렀을때: return .run { send in await send(.inner(.최근검색어_추가)) await send(.inner(.페이징_초기화), animation: .pokitDissolve) } - case .searchTextInputIconTapped: + + case .검색_버튼_눌렀을때: /// - 검색 중일 경우 `문자열 지우기 버튼 동작` if state.isSearching { state.domain.condition.searchWord = "" - return .send(.inner(.disableIsSearching)) + return .send(.inner(.검색창_활성화(false))) } else { return .run { send in await send(.inner(.최근검색어_추가)) await send(.inner(.페이징_초기화), animation: .pokitDissolve) } } - case .searchTextChipButtonTapped(text: let text): + + case let .최근검색_태그_눌렀을때(text): state.searchText = text return .send(.inner(.페이징_초기화), animation: .pokitDissolve) - case .filterButtonTapped: - return .send(.inner(.showFilterBottomSheet(filterType: .pokit))) - case .contentTypeFilterButtonTapped: - return .send(.inner(.showFilterBottomSheet(filterType: .contentType))) - case .dateFilterButtonTapped: + + case .필터링_버튼_눌렀을때: + return .send(.inner(.filterBottomSheet(filterType: .pokit))) + + case .모아보기_버튼_눌렀을때: + return .send(.inner(.filterBottomSheet(filterType: .contentType))) + + case .기간_버튼_눌렀을때: guard state.domain.condition.startDate != nil && state.domain.condition.endDate != nil else { /// - 선택된 기간이 없을 경우 - return .send(.inner(.showFilterBottomSheet(filterType: .date))) + return .send(.inner(.filterBottomSheet(filterType: .date))) } state.domain.condition.startDate = nil state.domain.condition.endDate = nil return .run { send in - await send(.inner(.updateDateFilter(startDate: nil, endDate: nil))) + await send(.inner(.기간_업데이트(startDate: nil, endDate: nil))) await send(.inner(.페이징_초기화), animation: .pokitDissolve) } - case .categoryFilterButtonTapped: - return .send(.inner(.showFilterBottomSheet(filterType: .pokit))) - case .recentSearchAllRemoveButtonTapped: + + case .카테고리_버튼_눌렀을때: + return .send(.inner(.filterBottomSheet(filterType: .pokit))) + + case .전체_삭제_버튼_눌렀을때: state.recentSearchTexts.removeAll() - return .send(.async(.최근검색어_갱신)) - case .recentSearchChipIconTapped(searchText: let searchText): + return .send(.async(.최근검색어_갱신_수행)) + + case let .최근검색_태그_삭제_눌렀을때(searchText): guard let predicate = state.recentSearchTexts.firstIndex(of: searchText) else { return .none } state.recentSearchTexts.remove(at: predicate) - return .send(.async(.최근검색어_갱신)) - case .linkCardTapped(content: let content): + return .send(.async(.최근검색어_갱신_수행)) + + case let .컨텐츠_항목_눌렀을때(content): return .send(.delegate(.linkCardTapped(content: content))) - case .kebabButtonTapped(content: let content): + + case let .컨텐츠_항목_케밥_버튼_눌렀을때(content): state.bottomSheetItem = content return .none - case .bottomSheetButtonTapped(delegate: let delegate, content: let content): + + case .bottomSheet(delegate: let delegate, content: let content): return .run { send in - await send(.inner(.dismissBottomSheet)) + await send(.inner(.바텀시트_해제)) await send(.scope(.bottomSheet(delegate: delegate, content: content))) } - case .deleteAlertConfirmTapped: + + case .링크_삭제_눌렀을때: guard let id = state.alertItem?.id else { return .none } state.alertItem = nil - return .run { [id] send in - await send(.async(.컨텐츠_삭제(id: id))) - } - case .sortTextLinkTapped: + return .send(.async(.컨텐츠_삭제_API(id: id))) + + case .정렬_버튼_눌렀을때: state.isResultAscending.toggle() state.domain.pageable.sort = [ state.isResultAscending ? "createdAt,asc" : "createdAt,desc" ] return .send(.inner(.페이징_초기화)) - case .backButtonTapped: - return .run { _ in - await dismiss() - } - case .onAppear: - return .run { [ - contentList = state.domain.contentList.data - ] send in - async let _ = send(.inner(.자동저장_켜기_불러오기)) - async let _ = send(.inner(.최근검색어_불러오기)) - if let contentList, !contentList.isEmpty { - async let _ = send(.async(.컨텐츠_검색)) - } - for await _ in self.pasteboard.changes() { - let url = try await pasteboard.probableWebURL() - await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) - } + case .dismiss: + return .run { _ in await dismiss() } + + case .뷰가_나타났을때: + let contentList = state.domain.contentList.data + + var effectBox: [Effect] = [ + .send(.inner(.자동저장_불러오기)), + .send(.inner(.최근검색어_불러오기)), + .send(.async(.클립보드_감지)) + ] + + if let contentList, !contentList.isEmpty { + effectBox.append(.send(.async(.컨텐츠_검색_API))) } - case .categoryFilterChipTapped(category: let category): + + return .merge(effectBox) + + case let .카테고리_태그_눌렀을때(category): state.categoryFilter.remove(category) return .run { send in - await send(.inner(.updateCategoryIds)) + await send(.inner(.카테고리_ID_목록_업데이트)) await send(.inner(.페이징_초기화)) } - case .favoriteChipTapped: + + case .즐겨찾기_태그_눌렀을때: state.domain.condition.favorites = false return .send(.inner(.페이징_초기화)) - case .unreadChipTapped: + + case .안읽음_태그_눌렀을때: state.domain.condition.isRead = false return .send(.inner(.페이징_초기화)) - case .링크_공유_완료: + + case .링크_공유_완료되었을때: state.shareSheetItem = nil return .none - case .로딩_isPresented: - return .send(.async(.컨텐츠_검색_결과_페이징_조회)) + case .로딩중일때: + return .send(.async(.컨텐츠_검색_페이징_API)) } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .enableIsSearching: - state.isSearching = true - return .none - case .disableIsSearching: - state.isSearching = false - state.domain.contentList.data = [] + case let .검색창_활성화(isActive): + if isActive { + state.isSearching = true + } else { + state.isSearching = false + state.domain.contentList.data = [] + } return .none - case .updateDateFilter(startDate: let startDate, endDate: let endDate): + case let .기간_업데이트(startDate, endDate): let formatter = DateFormat.dateFilter.formatter state.domain.condition.startDate = startDate state.domain.condition.endDate = endDate - guard let startDate, let endDate else { - /// - 날짜 필터가 선택 안되었을 경우 + guard let startDate, + let endDate else { + /// 🚨 Error Case : 날짜 필터가 선택 안되었을 경우 state.dateFilterText = "기간" return .none } - - if startDate == endDate { - /// - 날짜 필터를 하루만 선택했을 경우 - state.dateFilterText = "\(formatter.string(from: startDate))" - } else { - state.dateFilterText = "\(formatter.string(from: startDate))~\(formatter.string(from: endDate))" - } - + state.dateFilterText = startDate == endDate + ? "\(formatter.string(from: startDate))" /// - 날짜 필터를 하루만 선택했을 경우 + : "\(formatter.string(from: startDate))~\(formatter.string(from: endDate))" return .none - case .showFilterBottomSheet(filterType: let filterType): + + case let .filterBottomSheet(filterType): state.filterBottomSheet = .init( filterType: filterType, pokitFilter: state.categoryFilter, @@ -378,113 +386,91 @@ private extension PokitSearchFeature { endDateFilter: state.endDateFilter ) return .none - case .updateContentTypeFilter(favoriteFilter: let favoriteFilter, unreadFilter: let unreadFilter): + + case let .모아보기_업데이트(favoriteFilter, unreadFilter): state.domain.condition.favorites = favoriteFilter state.domain.condition.isRead = unreadFilter return .none - case .dismissBottomSheet: + + case .바텀시트_해제: state.bottomSheetItem = nil return .none - case .updateIsFiltered: - state.isFiltered = !state.categoryFilter.isEmpty || - state.favoriteFilter || - state.unreadFilter || - state.startDateFilter != nil || - state.endDateFilter != nil + + case .필터_업데이트: + state.isFiltered = !state.categoryFilter.isEmpty + || state.favoriteFilter + || state.unreadFilter + || state.startDateFilter != nil + || state.endDateFilter != nil return .none - case .updateCategoryIds: + + case .카테고리_ID_목록_업데이트: state.domain.condition.categoryIds = state.categoryFilter.map { $0.id } return .none - case .컨텐츠_목록_갱신(let contentList): + + case .컨텐츠_검색_API_반영(let contentList): state.domain.contentList = contentList - return .send(.inner(.enableIsSearching)) + + var contents = IdentifiedArrayOf() + contentList.data?.forEach { contents.append(.init(content: $0)) } + state.contents = contents + state.isLoading = false + return .send(.inner(.검색창_활성화(true))) + case .최근검색어_불러오기: - guard state.isAutoSaveSearch else { - return .none - } + guard state.isAutoSaveSearch else { return .none } state.recentSearchTexts = userDefaults.stringArrayKey(.searchWords) ?? [] return .none - case .자동저장_켜기_불러오기: + + case .자동저장_불러오기: state.isAutoSaveSearch = userDefaults.boolKey(.autoSaveSearch) return .none case .최근검색어_추가: - guard state.isAutoSaveSearch else { return .none } - guard !state.domain.condition.searchWord.isEmpty else { return .none } - if !state.recentSearchTexts.contains(state.domain.condition.searchWord) { - state.recentSearchTexts.append(state.domain.condition.searchWord) + let searchWord = state.domain.condition.searchWord + guard state.isAutoSaveSearch && !searchWord.isEmpty else { + /// 🚨 Error Case: 검색어 자동저장이 `off`거나, 검색 키워드가 없다면 종료 + return .none } - return .send(.async(.최근검색어_갱신)) - case .컨텐츠_삭제_반영(id: let id): + + if !state.recentSearchTexts.contains(searchWord) { + state.recentSearchTexts.append(searchWord) + } + return .send(.async(.최근검색어_갱신_수행)) + + 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 .컨텐츠_검색_결과_페이징_갱신(contentList): + + case let .컨텐츠_검색_페이징_API_반영(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 .send(.inner(.enableIsSearching)) + + newList.forEach { state.contents.append(.init(content: $0)) } + + return .send(.inner(.검색창_활성화(true))) + case .페이징_초기화: state.domain.pageable.page = 0 state.domain.contentList.data = nil - return .send(.async(.컨텐츠_검색), animation: .pokitDissolve) + state.isLoading = true + state.contents.removeAll() + return .send(.async(.컨텐츠_검색_API), animation: .pokitDissolve) } } /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .컨텐츠_검색: - let formatter = DateFormat.yearMonthDate.formatter + case .컨텐츠_검색_API: + return contentSearch(state: &state) - var startDateString: String? = nil - var endDateString: String? = nil - if let startDate = state.domain.condition.startDate { - startDateString = formatter.string(from: startDate) - } - if let endDate = state.domain.condition.endDate { - endDateString = formatter.string(from: endDate) - } - return .run { [ - pageable = state.domain.pageable, - condition = BaseConditionRequest( - searchWord: state.domain.condition.searchWord, - categoryIds: state.domain.condition.categoryIds, - isRead: state.domain.condition.isRead, - favorites: state.domain.condition.favorites, - startDate: startDateString, - endDate: endDateString - ) - ] send in - let stream = AsyncThrowingStream { continuation in - Task { - for page in 0...pageable.page { - let contentList = try await contentClient.컨텐츠_검색( - BasePageableRequest( - page: page, - size: pageable.size, - sort: pageable.sort - ), - condition - ).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(.컨텐츠_목록_갱신(contentItems)), animation: .pokitSpring) - } - case .최근검색어_갱신: + case .최근검색어_갱신_수행: guard state.isAutoSaveSearch else { return .none } return .run { [ searchWords = state.recentSearchTexts ] _ in await userDefaults.setStringArray( @@ -492,18 +478,19 @@ private extension PokitSearchFeature { .searchWords ) } - case .자동저장_켜기_갱신: - return .run { [ - isAutoSaveSearch = state.isAutoSaveSearch - ] send in + + case .자동저장_수행: + return .run { [isAutoSaveSearch = state.isAutoSaveSearch] _ in await userDefaults.setBool(isAutoSaveSearch, .autoSaveSearch) } - case .컨텐츠_삭제(id: let id): - return .run { [id] send in + + case let .컨텐츠_삭제_API(id): + return .run { send in let _ = try await contentClient.컨텐츠_삭제("\(id)") - await send(.inner(.컨텐츠_삭제_반영(id: id)), animation: .pokitSpring) + await send(.inner(.컨텐츠_삭제_API_반영(id: id)), animation: .pokitSpring) } - case .컨텐츠_검색_결과_페이징_조회: + + case .컨텐츠_검색_페이징_API: state.domain.pageable.page += 1 let formatter = DateFormat.yearMonthDate.formatter @@ -521,30 +508,44 @@ private extension PokitSearchFeature { startDateString, endDateString ] send in + let pageableRequest = BasePageableRequest( + page: pageable.page, + size: pageable.size, + sort: pageable.sort + ) + + let conditionRequest = BaseConditionRequest( + searchWord: condition.searchWord, + categoryIds: condition.categoryIds, + isRead: condition.isRead, + favorites: condition.favorites, + startDate: startDateString, + endDate: endDateString + ) + let contentList = try await contentClient.컨텐츠_검색( - BasePageableRequest( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ), - BaseConditionRequest( - searchWord: condition.searchWord, - categoryIds: condition.categoryIds, - isRead: condition.isRead, - favorites: condition.favorites, - startDate: startDateString, - endDate: endDateString - ) + pageableRequest, + conditionRequest ).toDomain() - await send(.inner(.컨텐츠_검색_결과_페이징_갱신(contentList))) + + await send(.inner(.컨텐츠_검색_페이징_API_반영(contentList))) + } + + case .클립보드_감지: + return .run { send in + for await _ in self.pasteboard.changes() { + let url = try await pasteboard.probableWebURL() + await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) + } } } } + /// - Scope Effect func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { switch action { - case .filterBottomSheet(.searchButtonTapped( + case .filterBottomSheet(.검색_버튼_눌렀을때( categories: let categories, isFavorite: let isFavorite, isUnread: let isUnread, @@ -552,12 +553,13 @@ private extension PokitSearchFeature { endDate: let endDate)): state.categoryFilter = categories return .run { send in - await send(.inner(.updateCategoryIds)) - await send(.inner(.updateContentTypeFilter(favoriteFilter: isFavorite, unreadFilter: isUnread))) - await send(.inner(.updateDateFilter(startDate: startDate, endDate: endDate))) - await send(.inner(.updateIsFiltered)) + await send(.inner(.카테고리_ID_목록_업데이트)) + await send(.inner(.모아보기_업데이트(favoriteFilter: isFavorite, unreadFilter: isUnread))) + await send(.inner(.기간_업데이트(startDate: startDate, endDate: endDate))) + await send(.inner(.필터_업데이트)) await send(.inner(.페이징_초기화)) } + case .bottomSheet(let delegate, let content): switch delegate { case .deleteCellButtonTapped: @@ -571,6 +573,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 } } @@ -581,8 +591,55 @@ private extension PokitSearchFeature { guard let contentList = state.domain.contentList.data, !contentList.isEmpty else { return .none } - return .send(.async(.컨텐츠_검색), animation: .pokitSpring) + return .send(.async(.컨텐츠_검색_API), animation: .pokitSpring) default: return .none } } + + func contentSearch(state: inout State) -> Effect { + let formatter = DateFormat.yearMonthDate.formatter + + var startDateString: String? = nil + var endDateString: String? = nil + if let startDate = state.domain.condition.startDate { + startDateString = formatter.string(from: startDate) + } + if let endDate = state.domain.condition.endDate { + endDateString = formatter.string(from: endDate) + } + let condition = BaseConditionRequest( + searchWord: state.domain.condition.searchWord, + categoryIds: state.domain.condition.categoryIds, + isRead: state.domain.condition.isRead, + favorites: state.domain.condition.favorites, + startDate: startDateString, + endDate: endDateString + ) + + return .run { [pageable = state.domain.pageable, condition] send in + let stream = AsyncThrowingStream { continuation in + Task { + for page in 0...pageable.page { + let request = BasePageableRequest( + page: page, + size: pageable.size, + sort: pageable.sort + ) + let contentList = try await contentClient.컨텐츠_검색(request, condition).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: .pokitSpring) + } + } } diff --git a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift index 8a1cfc22..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) @@ -54,13 +55,13 @@ public extension PokitSearchView { PokitBottomSheet( items: [.share, .edit, .delete], height: 224 - ) { send(.bottomSheetButtonTapped(delegate: $0, content: content)) } + ) { send(.bottomSheet(delegate: $0, content: content)) } } .sheet(item: $store.shareSheetItem) { content in if let shareURL = URL(string: content.data) { PokitShareSheet( items: [shareURL], - completion: { send(.링크_공유_완료) } + completion: { send(.링크_공유_완료되었을때) } ) .presentationDetents([.medium, .large]) } @@ -70,9 +71,9 @@ public extension PokitSearchView { "링크를 정말 삭제하시겠습니까?", message: "함께 저장한 모든 정보가 삭제되며, \n복구하실 수 없습니다.", confirmText: "삭제" - ) { send(.deleteAlertConfirmTapped) } + ) { send(.링크_삭제_눌렀을때) } } - .task { await send(.onAppear).finish() } + .task { await send(.뷰가_나타났을때).finish() } } } } @@ -82,7 +83,7 @@ private extension PokitSearchView { HStack(spacing: 8) { PokitToolbarButton( .icon(.arrowLeft), - action: { send(.backButtonTapped) } + action: { send(.dismiss) } ) PokitIconRInput( @@ -91,8 +92,8 @@ private extension PokitSearchView { shape: .round, focusState: $focused, equals: true, - onSubmit: { send(.searchTextInputOnSubmitted) }, - iconTappedAction: store.isSearching ? { send(.searchTextInputIconTapped) } : nil + onSubmit: { send(.검색_키보드_엔터_눌렀을때) }, + iconTappedAction: store.isSearching ? { send(.검색_버튼_눌렀을때) } : nil ) } .padding(.vertical, 8) @@ -110,7 +111,7 @@ private extension PokitSearchView { PokitTextLink( "전체 삭제", color: .text(.tertiary), - action: { send(.recentSearchAllRemoveButtonTapped) } + action: { send(.전체_삭제_버튼_눌렀을때) } ) Text("|") @@ -120,7 +121,7 @@ private extension PokitSearchView { PokitTextLink( "자동저장 \(store.isAutoSaveSearch ? "끄기" : "켜기")", color: .text(.tertiary), - action: { send(.autoSaveButtonTapped, animation: .pokitSpring) } + action: { send(.자동저장_버튼_눌렀을때, animation: .pokitSpring) } ) .contentTransition(.numericText()) } @@ -162,10 +163,10 @@ private extension PokitSearchView { state: .default(.primary), size: .small, action: { - send(.searchTextChipButtonTapped(text: text), animation: .pokitSpring) + send(.최근검색_태그_눌렀을때(text: text), animation: .pokitSpring) }, iconTappedAction: { - send(.recentSearchChipIconTapped(searchText: text), animation: .pokitSpring) + send(.최근검색_태그_삭제_눌렀을때(searchText: text), animation: .pokitSpring) } ) .pokitScrollTransition(.opacity) @@ -207,7 +208,7 @@ private extension PokitSearchView { state: store.isFiltered ? .filled(.primary) : .stroke(.secondary), size: .small, shape: .round, - action: { send(.filterButtonTapped) } + action: { send(.필터링_버튼_눌렀을때) } ) } @@ -219,7 +220,7 @@ private extension PokitSearchView { icon: .icon(.arrowDown), state: .default(.primary), size: .small, - action: { send(.categoryFilterButtonTapped) } + action: { send(.카테고리_버튼_눌렀을때) } ) } else { ForEach(store.categoryFilter) { category in @@ -227,7 +228,7 @@ private extension PokitSearchView { category.categoryName, state: .stroke(.primary), size: .small, - action: { send(.categoryFilterChipTapped(category: category), animation: .pokitSpring) } + action: { send(.카테고리_태그_눌렀을때(category: category), animation: .pokitSpring) } ) .pokitBlurReplaceTransition(.pokitDissolve) } @@ -243,7 +244,7 @@ private extension PokitSearchView { icon: .icon(.arrowDown), state: .default(.primary), size: .small, - action: { send(.contentTypeFilterButtonTapped) } + action: { send(.모아보기_버튼_눌렀을때) } ) } else { if store.favoriteFilter { @@ -251,7 +252,7 @@ private extension PokitSearchView { "즐겨찾기", state: .stroke(.primary), size: .small, - action: { send(.favoriteChipTapped, animation: .pokitSpring) } + action: { send(.즐겨찾기_태그_눌렀을때, animation: .pokitSpring) } ) .pokitBlurReplaceTransition(.pokitDissolve) } @@ -261,7 +262,7 @@ private extension PokitSearchView { "안읽음", state: .stroke(.primary), size: .small, - action: { send(.unreadChipTapped, animation: .pokitSpring) } + action: { send(.안읽음_태그_눌렀을때, animation: .pokitSpring) } ) .pokitBlurReplaceTransition(.pokitDissolve) } @@ -275,7 +276,7 @@ private extension PokitSearchView { icon: store.dateFilterText == "기간" ? .icon(.arrowDown) : .icon(.x), state: store.dateFilterText == "기간" ? .default(.primary) : .stroke(.primary), size: .small, - action: { send(.dateFilterButtonTapped, animation: .pokitSpring) } + action: { send(.기간_버튼_눌렀을때, animation: .pokitSpring) } ) .pokitBlurReplaceTransition(.pokitDissolve) } @@ -285,29 +286,30 @@ private extension PokitSearchView { PokitIconLTextLink( store.isResultAscending ? "오래된순" : "최신순", icon: .icon(.align), - action: { send(.sortTextLinkTapped) } + action: { send(.정렬_버튼_눌렀을때) } ) .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(.linkCardTapped(content: content)) }, - kebabAction: { send(.kebabButtonTapped(content: content)) } + ContentCardView( + store: store, + isFirst: isFirst, + isLast: isLast ) - .divider(isFirst: isFirst, isLast: isLast) } if store.hasNext { PokitLoading() - .task { await send(.로딩_isPresented, animation: .pokitDissolve).finish() } + .task { await send(.로딩중일때, animation: .pokitDissolve).finish() } } } .padding(.horizontal, 20) diff --git a/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomFeature.swift b/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomFeature.swift index 51da4855..95a2d5c3 100644 --- a/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomFeature.swift @@ -16,7 +16,7 @@ public struct FilterBottomFeature { /// - Dependency @Dependency(\.dismiss) private var dismiss - @Dependency(\.categoryClient) + @Dependency(CategoryClient.self) private var categoryClient /// - State @ObservableState @@ -73,31 +73,29 @@ public struct FilterBottomFeature { public enum View: Equatable, BindableAction { /// - Binding case binding(BindingAction) - /// - Button Tapped - case pokitListCellTapped(pokit: BaseCategoryItem) - case searchButtonTapped - case pokitChipTapped(BaseCategoryItem) - case favoriteChipTapped - case unreadChipTapped - case dateChipTapped - case favoriteButtonTapped - case unreadButtonTapped - - case pokitListOnAppeared + case 포킷_항목_눌렀을때(pokit: BaseCategoryItem) + case 검색하기_버튼_눌렀을때 + case 포킷_태그_눌렀을때(BaseCategoryItem) + case 즐겨찾기_태그_눌렀을때 + case 안읽음_태그_눌렀을때 + case 기간_태그_눌렀을때 + case 즐겨찾기_체크박스_눌렀을때 + case 안읽음_체크박스_눌렀을때 + case 뷰가_나타났을때 } public enum InnerAction: Equatable { - case 카테고리_목록_갱신(categoryList: BaseCategoryListInquiry) + case 카테고리_목록_조회_API_반영(categoryList: BaseCategoryListInquiry) } public enum AsyncAction: Equatable { - case 카테고리_목록_조회 + case 카테고리_목록_조회_API } public enum ScopeAction: Equatable { case doNothing } public enum DelegateAction: Equatable { - case searchButtonTapped( + case 검색_버튼_눌렀을때( categories: IdentifiedArrayOf, isFavorite: Bool, isUnread: Bool, @@ -149,15 +147,19 @@ private extension FilterBottomFeature { case .binding(\.startDate): state.dateSelected = true return .none + case .binding(\.endDate): state.dateSelected = true return .none + case .binding: return .none - case .pokitListCellTapped(let pokit): + + case .포킷_항목_눌렀을때(let pokit): state.selectedCategories.append(pokit) return .none - case .searchButtonTapped: + + case .검색하기_버튼_눌렀을때: return .run { [ categories = state.selectedCategories, isFavorite = state.isFavorite, @@ -166,7 +168,7 @@ private extension FilterBottomFeature { endDate = state.endDate, dateSelected = state.dateSelected ] send in - await send(.delegate(.searchButtonTapped( + await send(.delegate(.검색_버튼_눌렀을때( categories: categories, isFavorite: isFavorite, isUnread: isUnread, @@ -175,35 +177,42 @@ private extension FilterBottomFeature { ))) await dismiss() } - case .pokitChipTapped(let category): + + case .포킷_태그_눌렀을때(let category): state.selectedCategories.remove(category) return .none - case .favoriteChipTapped: + + case .즐겨찾기_태그_눌렀을때: state.isFavorite = false return .none - case .unreadChipTapped: + + case .안읽음_태그_눌렀을때: state.isUnread = false return .none - case .dateChipTapped: + + case .기간_태그_눌렀을때: state.startDate = .now state.endDate = .now state.dateSelected = false return .none - case .favoriteButtonTapped: + + case .즐겨찾기_체크박스_눌렀을때: state.isFavorite.toggle() return .none - case .unreadButtonTapped: + + case .안읽음_체크박스_눌렀을때: state.isUnread.toggle() return .none - case .pokitListOnAppeared: - return .send(.async(.카테고리_목록_조회)) + + case .뷰가_나타났을때: + return .send(.async(.카테고리_목록_조회_API)) } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .카테고리_목록_갱신(categoryList: let categoryList): + case let .카테고리_목록_조회_API_반영(categoryList): state.domain.categoryList = categoryList return .none } @@ -212,7 +221,7 @@ private extension FilterBottomFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .카테고리_목록_조회: + case .카테고리_목록_조회_API: return .run { [pageable = state.domain.pageable] send in let categoryList = try await categoryClient.카테고리_목록_조회( BasePageableRequest( @@ -222,7 +231,7 @@ private extension FilterBottomFeature { ), true ).toDomain() - await send(.inner(.카테고리_목록_갱신(categoryList: categoryList)), animation: .pokitDissolve) + await send(.inner(.카테고리_목록_조회_API_반영(categoryList: categoryList)), animation: .pokitDissolve) } } } diff --git a/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomSheet.swift b/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomSheet.swift index cc263553..724d1d08 100644 --- a/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomSheet.swift +++ b/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomSheet.swift @@ -35,7 +35,7 @@ public extension FilterBottomSheet { switch store.currentType { case .pokit: pokitList - .onAppear { send(.pokitListOnAppeared) } + .onAppear { send(.뷰가_나타났을때) } case .contentType: contentTypes case .date: @@ -53,7 +53,7 @@ public extension FilterBottomSheet { PokitBottomButton( "검색하기", state: .filled(.primary), - action: { send(.searchButtonTapped, animation: .pokitSpring) } + action: { send(.검색하기_버튼_눌렀을때, animation: .pokitSpring) } ) .padding(.horizontal, 20) } @@ -100,7 +100,7 @@ private extension FilterBottomSheet { PokitList( selectedItem: nil, list: pokitList, - action: { send(.pokitListCellTapped(pokit: $0), animation: .pokitSpring) } + action: { send(.포킷_항목_눌렀을때(pokit: $0), animation: .pokitSpring) } ) } else { PokitLoading() @@ -113,13 +113,13 @@ private extension FilterBottomSheet { contentTypeButton( "즐겨찾기", isSelected: $store.isFavorite, - action: { send(.favoriteButtonTapped, animation: .pokitSpring) } + action: { send(.즐겨찾기_체크박스_눌렀을때, animation: .pokitSpring) } ) contentTypeButton( "안읽음", isSelected: $store.isUnread, - action: { send(.unreadButtonTapped, animation: .pokitSpring) } + action: { send(.안읽음_체크박스_눌렀을때, animation: .pokitSpring) } ) Spacer() @@ -160,7 +160,7 @@ private extension FilterBottomSheet { category.categoryName, state: .stroke(.primary), size: .small, - action: { send(.pokitChipTapped(category), animation: .pokitSpring) } + action: { send(.포킷_태그_눌렀을때(category), animation: .pokitSpring) } ) .pokitBlurReplaceTransition(.pokitDissolve) } @@ -170,7 +170,7 @@ private extension FilterBottomSheet { "즐겨찾기", state: .stroke(.primary), size: .small, - action: { send(.favoriteChipTapped, animation: .pokitSpring) } + action: { send(.즐겨찾기_태그_눌렀을때, animation: .pokitSpring) } ) .pokitBlurReplaceTransition(.pokitDissolve) } @@ -180,7 +180,7 @@ private extension FilterBottomSheet { "안읽음", state: .stroke(.primary), size: .small, - action: { send(.unreadChipTapped, animation: .pokitSpring) } + action: { send(.안읽음_태그_눌렀을때, animation: .pokitSpring) } ) .pokitBlurReplaceTransition(.pokitDissolve) } @@ -191,7 +191,7 @@ private extension FilterBottomSheet { sameDate ? "\(store.startDateText)" : "\(store.startDateText)~\(store.endDateText)", state: .stroke(.primary), size: .small, - action: { send(.dateChipTapped, animation: .pokitSpring) } + action: { send(.기간_태그_눌렀을때, animation: .pokitSpring) } ) .pokitBlurReplaceTransition(.pokitDissolve) .contentTransition(.numericText()) @@ -222,7 +222,7 @@ private extension FilterBottomSheet { startDateFilter: nil, endDateFilter: nil ), - reducer: { FilterBottomFeature() } + reducer: { FilterBottomFeature()._printChanges() } ) ) } diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingFeature.swift b/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingFeature.swift index f08f9808..d0810b0b 100644 --- a/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingFeature.swift @@ -14,8 +14,8 @@ import Util public struct NickNameSettingFeature { /// - Dependency @Dependency(\.dismiss) var dismiss - @Dependency(\.userClient) var userClient @Dependency(\.mainQueue) var mainQueue + @Dependency(UserClient.self) var userClient /// - State @ObservableState public struct State: Equatable { @@ -46,25 +46,24 @@ public struct NickNameSettingFeature { public enum View: BindableAction, Equatable { case binding(BindingAction) case dismiss - case saveButtonTapped - - case onAppear + case 저장_버튼_눌렀을때 + case 뷰가_나타났을때 } public enum InnerAction: Equatable { - case textChanged - case 닉네임_중복_체크_네트워크_결과(Bool) - case 유저정보_갱신(BaseUser) + case 닉네임_텍스트_변경되었을때 + case 닉네임_중복_확인_API_반영(Bool) + case 닉네임_조회_API_반영(BaseUser) } public enum AsyncAction: Equatable { - case 닉네임_중복_체크_네트워크 - case 닉네임_조회 + case 닉네임_중복_확인_API + case 닉네임_조회_API } - public enum ScopeAction: Equatable { case doNothing } + public enum ScopeAction: Equatable { case 없음 } - public enum DelegateAction: Equatable { case doNothing } + public enum DelegateAction: Equatable { case 없음 } } /// - Initiallizer @@ -109,34 +108,36 @@ private extension NickNameSettingFeature { case .binding(\.text): state.buttonState = .disable return .run { send in - await send(.inner(.textChanged)) + await send(.inner(.닉네임_텍스트_변경되었을때)) } .debounce( id: CancelID.response, for: 0.5, scheduler: mainQueue ) + case .binding: return .none case .dismiss: return .run { _ in await dismiss() } - case .saveButtonTapped: + case .저장_버튼_눌렀을때: return .run { [nickName = state.text] send in let request = NicknameEditRequest(nickname: nickName) let _ = try await userClient.닉네임_수정(request) await dismiss() } - case .onAppear: - return .send(.async(.닉네임_조회)) + + case .뷰가_나타났을때: + return .send(.async(.닉네임_조회_API)) } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { - case .textChanged: + case .닉네임_텍스트_변경되었을때: /// [1]. 닉네임 텍스트필드가 비어있을 때 if state.text.isEmpty { state.buttonState = .disable @@ -155,18 +156,20 @@ private extension NickNameSettingFeature { return .none } else { /// [4]. 정상 케이스일 때 - return .run { send in await send(.async(.닉네임_중복_체크_네트워크)) } - } - case let .닉네임_중복_체크_네트워크_결과(isDuplicate): - if isDuplicate { - state.textfieldState = .error(message: "중복된 닉네임입니다.") - state.buttonState = .disable - } else { - state.textfieldState = .active - state.buttonState = .filled(.primary) + return .run { send in await send(.async(.닉네임_중복_확인_API)) } } + + case let .닉네임_중복_확인_API_반영(isDuplicate): + state.textfieldState = isDuplicate + ? .error(message: "중복된 닉네임입니다.") + : .active + + state.buttonState = isDuplicate + ? .disable + : .filled(.primary) return .none - case let .유저정보_갱신(user): + + case let .닉네임_조회_API_반영(user): state.domain.user = user state.domain.nickname = user.nickname return .none @@ -176,15 +179,16 @@ private extension NickNameSettingFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .닉네임_중복_체크_네트워크: + case .닉네임_중복_확인_API: return .run { [nickName = state.text] send in let result = try await userClient.닉네임_중복_체크(nickName) - await send(.inner(.닉네임_중복_체크_네트워크_결과(result.isDuplicate))) + await send(.inner(.닉네임_중복_확인_API_반영(result.isDuplicate))) } - case .닉네임_조회: + + case .닉네임_조회_API: return .run { send in let user = try await userClient.닉네임_조회().toDomain() - await send(.inner(.유저정보_갱신(user)), animation: .easeInOut) + await send(.inner(.닉네임_조회_API_반영(user)), animation: .easeInOut) } } } diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingView.swift b/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingView.swift index 69e92718..f22a4af0 100644 --- a/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingView.swift +++ b/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingView.swift @@ -44,7 +44,7 @@ public extension NickNameSettingView { PokitBottomButton( "저장", state: store.buttonState, - action: { send(.saveButtonTapped) } + action: { send(.저장_버튼_눌렀을때) } ) .setKeyboardHeight() } @@ -52,7 +52,7 @@ public extension NickNameSettingView { .padding(.top, 16) .pokitNavigationBar { navigationBar } .ignoresSafeArea(edges: .bottom) - .task { await send(.onAppear).finish() } + .task { await send(.뷰가_나타났을때).finish() } } } } diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift index 698db99e..f08dadbc 100644 --- a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift @@ -13,13 +13,20 @@ import Util @Reducer public struct PokitSettingFeature { /// - Dependency - @Dependency(\.dismiss) var dismiss - @Dependency(\.openSettings) var openSetting - @Dependency(\.pasteboard) var pasteboard - @Dependency(\.keychain) var keychain - @Dependency(\.userDefaults) var userDefaults - @Dependency(\.authClient) var authClient - @Dependency(\.openURL) var openURL + @Dependency(\.dismiss) + var dismiss + @Dependency(\.openURL) + var openURL + @Dependency(\.openSettings) + var openSetting + @Dependency(PasteboardClient.self) + var pasteboard + @Dependency(KeychainClient.self) + var keychain + @Dependency(UserDefaultsClient.self) + var userDefaults + @Dependency(AuthClient.self) + var authClient /// - State @ObservableState public struct State: Equatable { @@ -43,18 +50,18 @@ public struct PokitSettingFeature { @CasePathable public enum View: BindableAction, Equatable { case binding(BindingAction) + case dismiss case 닉네임설정 case 알림설정 case 공지사항 case 서비스_이용약관 case 개인정보_처리방침 case 고객문의 - case 로그아웃 - case 로그아웃수행 - case 회원탈퇴 - case 회원탈퇴수행 - case dismiss - case onAppear + case 로그아웃_버튼_눌렀을때 + case 로그아웃_팝업_확인_눌렀을때 + case 회원탈퇴_버튼_눌렀을때 + case 회원탈퇴_팝업_확인_눌렀을때 + case 뷰가_나타났을때 } public enum InnerAction: Equatable { @@ -63,13 +70,11 @@ public struct PokitSettingFeature { } public enum AsyncAction: Equatable { - case 회원탈퇴_네트워크 - case 키_제거 + case 회원탈퇴_API + case 키_제거_수행 } - public enum ScopeAction: Equatable { - case doNothing - } + public enum ScopeAction: Equatable { case 없음 } public enum DelegateAction: Equatable { case linkCopyDetected(URL?) @@ -126,6 +131,9 @@ private extension PokitSettingFeature { case .binding: return .none + case .dismiss: + return .run { _ in await dismiss() } + case .닉네임설정: state.nickNameSettingState = NickNameSettingFeature.State() return .none @@ -153,30 +161,27 @@ private extension PokitSettingFeature { state.isWebViewPresented = true return .none - case .로그아웃: + case .로그아웃_버튼_눌렀을때: return .send(.inner(.로그아웃_팝업(isPresented: true))) - case .로그아웃수행: + case .로그아웃_팝업_확인_눌렀을때: return .run { send in - await send(.async(.키_제거)) + await send(.async(.키_제거_수행)) await send(.inner(.로그아웃_팝업(isPresented: false))) await send(.delegate(.로그아웃)) } - case .회원탈퇴: + case .회원탈퇴_버튼_눌렀을때: return .send(.inner(.회원탈퇴_팝업(isPresented: true))) - case .회원탈퇴수행: + case .회원탈퇴_팝업_확인_눌렀을때: return .run { send in - await send(.async(.회원탈퇴_네트워크)) + await send(.async(.회원탈퇴_API)) await send(.inner(.회원탈퇴_팝업(isPresented: false))) await send(.delegate(.회원탈퇴)) } - case .dismiss: - return .run { _ in await dismiss() } - - case .onAppear: + case .뷰가_나타났을때: return .run { send in for await _ in self.pasteboard.changes() { let url = try await pasteboard.probableWebURL() @@ -202,7 +207,7 @@ private extension PokitSettingFeature { /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .회원탈퇴_네트워크: + case .회원탈퇴_API: return .run { send in guard keychain.read(.refreshToken) != nil else { print("refresh가 없어서 벗어남") @@ -235,17 +240,14 @@ private extension PokitSettingFeature { ) } - await send(.async(.키_제거)) + await send(.async(.키_제거_수행)) - let request = WithdrawRequest( - authPlatform: platform - ) + let request = WithdrawRequest(authPlatform: platform) - // - TODO: 서버 디비 삭제 요청으로 바꿔야함 try await authClient.회원탈퇴(request) } - case .키_제거: + case .키_제거_수행: keychain.delete(.accessToken) keychain.delete(.refreshToken) keychain.delete(.serverRefresh) diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift index 87dde923..b092f054 100644 --- a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift +++ b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift @@ -37,7 +37,7 @@ public extension PokitSettingView { PokitAlert( "로그아웃 하시겠습니까?", confirmText: "로그아웃", - action: { send(.로그아웃수행) } + action: { send(.로그아웃_팝업_확인_눌렀을때) } ) } .sheet(isPresented: $store.isWithdrawPresented) { @@ -45,7 +45,7 @@ public extension PokitSettingView { "회원 탈퇴하시겠습니까?", message: "함께 저장한 모든 정보가 삭제되며,\n복구하실 수 없습니다.", confirmText: "탈퇴하기", - action: { send(.회원탈퇴수행) } + action: { send(.회원탈퇴_팝업_확인_눌렀을때) } ) } .fullScreenCover(isPresented: $store.isWebViewPresented) { @@ -60,7 +60,7 @@ public extension PokitSettingView { ) { store in NickNameSettingView(store: store) } - .onAppear { send(.onAppear) } + .onAppear { send(.뷰가_나타났을때) } } } } @@ -114,12 +114,12 @@ private extension PokitSettingView { Section { SettingItem( title: "로그아웃", - action: { send(.로그아웃) } + action: { send(.로그아웃_버튼_눌렀을때) } ) SettingItem( title: "회원 탈퇴", - action: { send(.회원탈퇴) } + action: { send(.회원탈퇴_버튼_눌렀을때) } ) } } diff --git a/Projects/Feature/FeatureSettingTests/Resources/info.plist b/Projects/Feature/FeatureSettingTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureSettingTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureSettingTests/Sources/FeatureSettingTests.swift b/Projects/Feature/FeatureSettingTests/Sources/FeatureSettingTests.swift new file mode 100644 index 00000000..32a3e41a --- /dev/null +++ b/Projects/Feature/FeatureSettingTests/Sources/FeatureSettingTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureSetting + +final class FeatureSettingTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift index c2b7bfa5..d59c1746 100644 --- a/Projects/Feature/Project.swift +++ b/Projects/Feature/Project.swift @@ -16,7 +16,11 @@ let demoTargets = Feature.allCases.map { feature in return feature.demoTarget } +let testTargets = Feature.allCases.map { feature in + return feature.testTarget +} + let project = Project( name: "Feature", - targets: targets + demoTargets + targets: targets + demoTargets + testTargets ) diff --git a/Projects/SharedThirdPartyLib/Project.swift b/Projects/SharedThirdPartyLib/Project.swift index ff80eec2..a362e892 100644 --- a/Projects/SharedThirdPartyLib/Project.swift +++ b/Projects/SharedThirdPartyLib/Project.swift @@ -24,7 +24,7 @@ let project = Project( // TODO: 의존성 추가 .external(name: "ComposableArchitecture") ], - settings: .settings + settings: .settings() ) ] ) diff --git a/Projects/Util/Project.swift b/Projects/Util/Project.swift index e5c44d37..30f6daf6 100644 --- a/Projects/Util/Project.swift +++ b/Projects/Util/Project.swift @@ -22,7 +22,8 @@ let project = Project( sources: ["Sources/**"], dependencies: [ // TODO: 의존성 추가 - ] + ], + settings: .settings() ) ] ) diff --git a/Tuist/ProjectDescriptionHelpers/Feature.swift b/Tuist/ProjectDescriptionHelpers/Feature.swift index 631cda83..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")) ] @@ -45,4 +46,48 @@ public enum Feature: String, CaseIterable { ] ) } + + public var testTarget: Target { + return .makeTarget( + name: "Feature\(self.rawValue)Tests", + product: .unitTests, + bundleName: "Feature.\(self.rawValue)Tests", + infoPlist: .dictionary(["ENABLE_TESTING_SEARCH_PATHS": "YES"]), + resources: ["Feature\(self.rawValue)Tests/Resources/**"], + dependencies: [ + .target(self.target) + ] + ) + } + + 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/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift index d869536e..47a914d2 100644 --- a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift @@ -8,11 +8,21 @@ import ProjectDescription public extension Settings { - static var settings: Settings { - return .settings( + static func settings(_ release: Configuration? = nil) -> Settings { + var settings: Settings = .settings( base: [ "OTHER_LDFLAGS": "$(inherited) -ObjC", + "CODE_SIGN_STYLE": "Manual" + ], + configurations: [ + .debug(name: "Debug", xcconfig: .relativeToRoot("xcconfig/Debug.xcconfig")) ] ) + + if let release { + settings.configurations.append(release) + } + + return settings } } diff --git a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift index fe2bcc3c..486c516f 100644 --- a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift @@ -26,7 +26,7 @@ public extension Target { sources: ["\(name)/Sources/**"], resources: resources, dependencies: dependencies, - settings: .settings + settings: .settings() ) } @@ -44,7 +44,7 @@ public extension Target { deploymentTargets: .appMinimunTarget, sources: ["Sources/\(name)/Sources/**"], dependencies: dependencies, - settings: .settings + settings: .settings() ) } } diff --git a/Tuist/Templates/test/InfoPlist.stencil b/Tuist/Templates/test/InfoPlist.stencil new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Tuist/Templates/test/InfoPlist.stencil @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Tuist/Templates/test/Test.stencil b/Tuist/Templates/test/Test.stencil new file mode 100644 index 00000000..c0c58ef3 --- /dev/null +++ b/Tuist/Templates/test/Test.stencil @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import {{ target }} + +final class {{ target }}Tests: XCTestCase { + func test() { + + } +} diff --git a/Tuist/Templates/test/test.swift b/Tuist/Templates/test/test.swift new file mode 100644 index 00000000..ec183842 --- /dev/null +++ b/Tuist/Templates/test/test.swift @@ -0,0 +1,27 @@ +// +// test.swift +// ProjectDescriptionHelpers +// +// Created by 김민호 on 10/10/24. +// + +import ProjectDescription + +let appProjectAttribute: Template.Attribute = .required("project") +let appTargetAttribute: Template.Attribute = .required("target") + +let appTemplate = Template( + description: "Target template", + attributes: [ + appProjectAttribute, + appTargetAttribute + ], + items: [ + .file( + path: "Projects/\(appProjectAttribute)/\(appTargetAttribute)Tests/Sources/\(appTargetAttribute)Tests.swift", + templatePath: "Test.stencil"), + .file( + path: "Projects/\(appProjectAttribute)/\(appTargetAttribute)Tests/Resources/info.plist", + templatePath: "InfoPlist.stencil"), + ] +) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 6dcc5811..164214f1 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -16,45 +16,37 @@ default_platform(:ios) platform :ios do - lane :generate do + lane :appstore_profile do setup_ci match( type: "appstore", - app_identifier:["com.pokitmons.pokit"], + app_identifier:["com.pokitmons.pokit", "com.pokitmons.pokit.ShareExtension"], readonly: true ) - - Dir.chdir("../") do - sh("make release") - end end - lane :test do + lane :development_profile do setup_ci match( - type: "appstore", - app_identifier:["com.pokitmons.pokit"], + type: "development", + app_identifier:["com.pokitmons.pokit", "com.pokitmons.pokit.ShareExtension"], readonly: true ) - - Dir.chdir("../") do - sh("make test") - end end lane :build do build_app( workspace: "Pokit.xcworkspace", scheme: "App", - export_method: "app-store", + configuration: "Debug", + export_method: "development", export_options: { - method: "app-store", - signingStyle: "manual", - provisioningProfiles: { - "com.pokitmons.pokit" => "match AppStore com.pokitmons.pokit 1721720816" - } + provisioningProfiles: { + "com.pokitmons.pokit" => "match Development com.pokitmons.pokit", + "com.pokitmons.pokit.ShareExtension" => "match Development com.pokitmons.pokit.ShareExtension" + } } ) end @@ -79,18 +71,18 @@ platform :ios do build_app( workspace: "Pokit.xcworkspace", scheme: "App", + configuration: "Release", export_method: "app-store", export_options: { - method: "app-store", - signingStyle: "manual", - provisioningProfiles: { - "com.pokitmons.pokit" => "match AppStore com.pokitmons.pokit 1721720816" - } + provisioningProfiles: { + "com.pokitmons.pokit" => "match AppStore com.pokitmons.pokit", + "com.pokitmons.pokit.ShareExtension" => "match AppStore com.pokitmons.pokit.ShareExtension" + } } ) end - lane :release do + lane :testflight_release do api_key = app_store_connect_api_key( key_id: ENV['APP_STORE_CONNECT_KEY_ID'], issuer_id: ENV['APP_STORE_CONNECT_ISSUER_ID'], @@ -99,8 +91,60 @@ platform :ios do upload_to_testflight( api_key: api_key, - distribute_external: false, + distribute_external: true, + groups: ["Pokitmons"], changelog: "" ) end + + lane :appstore_release do + api_key = app_store_connect_api_key( + key_id: ENV['APP_STORE_CONNECT_KEY_ID'], + issuer_id: ENV['APP_STORE_CONNECT_ISSUER_ID'], + key_content: ENV['APP_STORE_CONNECT_API'] + ) + + release_notes = File.read("release_notes.txt") + + upload_to_app_store( + api_key: api_key, + skip_metadata: false, + skip_screenshots: true, + skip_binary_upload: true, + precheck_include_in_app_purchases: false, + release_notes: { + 'default' => release_notes + }, + submit_for_review: true, + automatic_release: true, + force: true + ) + end + + lane :update_github_release do + require 'spaceship' + + api_key = app_store_connect_api_key( + key_id: ENV['APP_STORE_CONNECT_KEY_ID'], + issuer_id: ENV['APP_STORE_CONNECT_ISSUER_ID'], + key_content: ENV['APP_STORE_CONNECT_API'] + ) + + Spaceship::ConnectAPI.login(api_key: api_key) + + app = Spaceship::ConnectAPI::App.find("com.pokitmons.pokit") + + live_version = app.get_live_version + app_state = live_version.app_store_state + app_version = live_version.version_string + + if app_state == 'READY_FOR_SALE' + pr_number = ENV['PR_NUMBER'] + pr_body = sh("gh pr view #{pr_number} --json body --jq '.body'") + release_notes = pr_body.strip + + # GitHub 릴리즈 버전 및 릴리즈 노트 업데이트 + sh("gh release create v#{app_version} --notes '#{release_notes}'") + end + end end diff --git a/fastlane/Matchfile b/fastlane/Matchfile index 591d5fd3..6648ebc2 100644 --- a/fastlane/Matchfile +++ b/fastlane/Matchfile @@ -1,8 +1,8 @@ -git_url(ENV['MATCH_URL']) +git_url('git@github.com:stealmh/Pokit_iOS_Private.git') storage_mode("git") -type("appstore") # The default type, can be: appstore, adhoc, enterprise or development +type("development") # The default type, can be: appstore, adhoc, enterprise or development app_identifier(["com.pokitmons.pokit"]) username("shapekim98@gmail.com") # Your Apple Developer Portal username diff --git a/fastlane/README.md b/fastlane/README.md index 891747df..dd35e59d 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -15,13 +15,61 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do ## iOS -### ios beta +### ios appstore_profile ```sh -[bundle exec] fastlane ios beta +[bundle exec] fastlane ios appstore_profile ``` -Push a new beta build to TestFlight + + +### ios development_profile + +```sh +[bundle exec] fastlane ios development_profile +``` + + + +### ios build + +```sh +[bundle exec] fastlane ios build +``` + + + +### ios archive + +```sh +[bundle exec] fastlane ios archive +``` + + + +### ios testflight_release + +```sh +[bundle exec] fastlane ios testflight_release +``` + + + +### ios appstore_release + +```sh +[bundle exec] fastlane ios appstore_release +``` + + + +### ios update_github_release + +```sh +[bundle exec] fastlane ios update_github_release +``` + + ---- diff --git a/fastlane/release_notes.txt b/fastlane/release_notes.txt new file mode 100644 index 00000000..5baff929 --- /dev/null +++ b/fastlane/release_notes.txt @@ -0,0 +1,3 @@ +- 이제부터 어디서든 아이폰 공유하기로 링크를 저장할 수 있어요. +- 인스타그램 링크에서 썸네일이 나오지 않는 문제를 수성했어요. +- 자잘한 버그 및 사용성을 개선했어요. \ No newline at end of file diff --git a/fastlane/report.xml b/fastlane/report.xml index 89ad2f28..9d5a5bf3 100644 --- a/fastlane/report.xml +++ b/fastlane/report.xml @@ -5,12 +5,17 @@ - + - + + + + + + diff --git a/graph.png b/graph.png index 328dd1ec..d8bc8e12 100644 Binary files a/graph.png and b/graph.png differ