diff --git a/.github/workflows/appstore_release.yml b/.github/workflows/appstore_release.yml index bd63256e..0f63aeb7 100644 --- a/.github/workflows/appstore_release.yml +++ b/.github/workflows/appstore_release.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest-stable + xcode-version: '16.1' - uses: shimataro/ssh-key-action@v2 with: diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index d27957cc..f7b026c3 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest-stable + xcode-version: '16.1' - uses: shimataro/ssh-key-action@v2 with: diff --git a/.github/workflows/testflight_release.yml b/.github/workflows/testflight_release.yml index aaf83977..6b7a0b6f 100644 --- a/.github/workflows/testflight_release.yml +++ b/.github/workflows/testflight_release.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest-stable + xcode-version: '16.1' - uses: shimataro/ssh-key-action@v2 with: diff --git a/Makefile b/Makefile index 372403c0..b495af9d 100644 --- a/Makefile +++ b/Makefile @@ -17,8 +17,7 @@ test: make clean make download tuist install - tuist cache - TUIST_DEVELOPMENT_TEAM=$(DEVELOPMENT_TEAM) tuist generate App + TUIST_DEVELOPMENT_TEAM=$(DEVELOPMENT_TEAM) tuist generate App --verbose clean: tuist clean diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 51ce1d00..3a6b76a7 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -26,7 +26,7 @@ let shareExtensionTarget: Target = .target( resources: ["ShareExtension/Resources/**"], entitlements: .file(path: .relativeToRoot("Projects/App/ShareExtension/ShareExtension.entitlements")), dependencies: [ - .project(target: "FeatureLogin", path: .relativeToRoot("Projects/Feature")), + .project(target: "FeatureIntro", path: .relativeToRoot("Projects/Feature")), .project(target: "FeatureContentSetting", path: .relativeToRoot("Projects/Feature")), .project(target: "FeatureCategorySetting", path: .relativeToRoot("Projects/Feature")) ], diff --git a/Projects/App/Resources/LaunchScreen.storyboard b/Projects/App/Resources/LaunchScreen.storyboard index c8502224..3e576b0e 100644 --- a/Projects/App/Resources/LaunchScreen.storyboard +++ b/Projects/App/Resources/LaunchScreen.storyboard @@ -1,9 +1,9 @@ - + - + @@ -17,18 +17,20 @@ - + - + + - + + @@ -40,7 +42,7 @@ - + diff --git a/Projects/App/Resources/Pokit-info.plist b/Projects/App/Resources/Pokit-info.plist index 1cce504b..98d454b2 100644 --- a/Projects/App/Resources/Pokit-info.plist +++ b/Projects/App/Resources/Pokit-info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.9 + 2.0.0 CFBundleURLTypes diff --git a/Projects/App/ShareExtension/Sources/ShareRootFeature.swift b/Projects/App/ShareExtension/Sources/ShareRootFeature.swift index 0634c43d..c34a830e 100644 --- a/Projects/App/ShareExtension/Sources/ShareRootFeature.swift +++ b/Projects/App/ShareExtension/Sources/ShareRootFeature.swift @@ -9,7 +9,7 @@ import UIKit import UniformTypeIdentifiers import ComposableArchitecture -import FeatureLogin +import FeatureIntro import FeatureContentSetting import FeatureCategorySetting import CoreKit diff --git a/Projects/App/ShareExtension/Sources/ShareRootView.swift b/Projects/App/ShareExtension/Sources/ShareRootView.swift index f538be53..d62c665e 100644 --- a/Projects/App/ShareExtension/Sources/ShareRootView.swift +++ b/Projects/App/ShareExtension/Sources/ShareRootView.swift @@ -8,7 +8,7 @@ import SwiftUI import ComposableArchitecture -import FeatureLogin +import FeatureIntro import FeatureContentSetting import FeatureCategorySetting import DSKit diff --git a/Projects/App/Sources/MainTab/MainTab.swift b/Projects/App/Sources/MainTab/MainTab.swift index 458aa906..b82eb0c5 100644 --- a/Projects/App/Sources/MainTab/MainTab.swift +++ b/Projects/App/Sources/MainTab/MainTab.swift @@ -11,14 +11,14 @@ import DSKit public enum MainTab: String, CaseIterable { case pokit = "포킷" - case remind = "리마인드" + case recommend = "링크추천" var title: String { return self.rawValue } var icon: PokitImage { switch self { case .pokit: return .icon(.folderFill) - case .remind: return .icon(.remind) + case .recommend: return .icon(.remind) } } } diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift index f0684502..186d6bda 100644 --- a/Projects/App/Sources/MainTab/MainTabFeature.swift +++ b/Projects/App/Sources/MainTab/MainTabFeature.swift @@ -8,7 +8,7 @@ import SwiftUI import ComposableArchitecture import FeaturePokit -import FeatureRemind +import FeatureRecommend import FeatureContentDetail import Domain import DSKit @@ -37,7 +37,7 @@ public struct MainTabFeature { var path: StackState = .init() var pokit: PokitRootFeature.State - var remind: RemindFeature.State = .init() + var recommend: RecommendFeature.State = .init() @Presents var contentDetail: ContentDetailFeature.State? @Shared(.inMemory("SelectCategory")) var categoryId: Int? @Shared(.inMemory("PushTapped")) var isPushTapped: Bool = false @@ -59,7 +59,7 @@ public struct MainTabFeature { /// Todo: scope로 이동 case path(StackAction) case pokit(PokitRootFeature.Action) - case remind(RemindFeature.Action) + case recommend(RecommendFeature.Action) case contentDetail(PresentationAction) @CasePathable @@ -70,6 +70,8 @@ public struct MainTabFeature { case onAppear case onOpenURL(url: URL) case 경고_확인버튼_클릭 + case 검색_버튼_눌렀을때 + case 알림_버튼_눌렀을때 } public enum InnerAction: Equatable { case 링크추가및수정이동(contentId: Int) @@ -129,7 +131,7 @@ public struct MainTabFeature { return .none case .pokit: return .none - case .remind: + case .recommend: return .none case .contentDetail: return .none @@ -138,7 +140,9 @@ public struct MainTabFeature { /// - Reducer body public var body: some ReducerOf { Scope(state: \.pokit, action: \.pokit) { PokitRootFeature() } - Scope(state: \.remind, action: \.remind) { RemindFeature() } + Scope(state: \.recommend, action: \.recommend) { + RecommendFeature() + } BindingReducer() navigationReducer @@ -198,6 +202,28 @@ private extension MainTabFeature { case .경고_확인버튼_클릭: state.error = nil return .run { send in await send(.inner(.errorSheetPresented(false))) } + case .검색_버튼_눌렀을때: + switch state.selectedTab { + case .pokit: return .none + case .recommend: + return RecommendFeature() + .reduce( + into: &state.recommend, + action: .view(.검색_버튼_눌렀을때) + ) + .map(Action.recommend) + } + case .알림_버튼_눌렀을때: + switch state.selectedTab { + case .pokit: return .none + case .recommend: + return RecommendFeature() + .reduce( + into: &state.recommend, + action: .view(.알림_버튼_눌렀을때) + ) + .map(Action.recommend) + } } } /// - Inner Effect @@ -224,7 +250,7 @@ private extension MainTabFeature { state.linkPopup = type return .none case let .카테고리상세_이동(category): - if category.categoryName == "미분류" { + if category.categoryName == Constants.미분류 { state.selectedTab = .pokit state.path.removeAll() return .send(.pokit(.delegate(.미분류_카테고리_활성화))) @@ -270,7 +296,7 @@ private extension MainTabFeature { guard let category = state.categoryOfSavedContent else { return .none } state.categoryOfSavedContent = nil return .send(.inner(.카테고리상세_이동(category: category))) - case .error, .text, .warning, .none: + case .error, .text, .warning, .report, .none: return .none } } diff --git a/Projects/App/Sources/MainTab/MainTabFeatureView.swift b/Projects/App/Sources/MainTab/MainTabFeatureView.swift index 3e695dd4..0fbfd373 100644 --- a/Projects/App/Sources/MainTab/MainTabFeatureView.swift +++ b/Projects/App/Sources/MainTab/MainTabFeatureView.swift @@ -9,7 +9,7 @@ import SwiftUI import ComposableArchitecture import DSKit import FeaturePokit -import FeatureRemind +import FeatureRecommend import FeatureSetting import FeatureCategorySetting import FeatureContentDetail @@ -135,8 +135,8 @@ private extension MainTabView { .pokitNavigationBar { pokitNavigationBar } .toolbarBackground(.hidden, for: .tabBar) - case .remind: - RemindView(store: store.scope(state: \.remind, action: \.remind)) + case .recommend: + RecommendView(store: store.scope(state: \.recommend, action: \.recommend)) .pokitNavigationBar { remindNavigationBar } .toolbarBackground(.hidden, for: .tabBar) } @@ -173,19 +173,19 @@ private extension MainTabView { var remindNavigationBar: some View { PokitHeader { PokitHeaderItems(placement: .leading) { - Text("Remind") - .font(.system(size: 32, weight: .heavy)) - .foregroundStyle(.pokit(.text(.brand))) + Text("링크추천") + .pokitFont(.title2) + .foregroundStyle(.pokit(.text(.primary))) } PokitHeaderItems(placement: .trailing) { PokitToolbarButton( .icon(.search), - action: { store.send(.remind(.view(.검색_버튼_눌렀을때))) } + action: { send(.검색_버튼_눌렀을때) } ) PokitToolbarButton( .icon(.bell), - action: { store.send(.remind(.view(.알림_버튼_눌렀을때))) } + action: { send(.알림_버튼_눌렀을때) } ) } } @@ -198,7 +198,7 @@ private extension MainTabView { Spacer() - bottomTabBarItem(.remind) + bottomTabBarItem(.recommend) } .padding(.horizontal, 48) .padding(.top, 12) @@ -272,50 +272,53 @@ private extension MainTabView { var action: (TabAddSheetType) -> Void var body: some View { - HStack(spacing: 20) { - Spacer() - - ForEach(TabAddSheetType.allCases, id: \.self) { type in - Button(action: { action(type) }) { - VStack(spacing: 4) { - Spacer() - - type.icon - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 28, height: 28) - .foregroundStyle(.pokit(.icon(.inverseWh))) - - Text(type.title) - .pokitFont(.b3(.m)) - .foregroundStyle(.pokit(.text(.inverseWh))) - - Spacer() - } - .padding(.horizontal, 24) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .foregroundStyle(.pokit(.bg(.brand))) + GeometryReader { proxy in + let bottomSafeArea = proxy.safeAreaInsets.bottom + HStack(spacing: 20) { + Spacer() + + ForEach(TabAddSheetType.allCases, id: \.self) { type in + Button(action: { action(type) }) { + VStack(spacing: 4) { + Spacer() + + type.icon + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .foregroundStyle(.pokit(.icon(.inverseWh))) + + Text(type.title) + .pokitFont(.b3(.m)) + .foregroundStyle(.pokit(.text(.inverseWh))) + + Spacer() + } + .padding(.horizontal, 24) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .foregroundStyle(.pokit(.bg(.brand))) + } + .frame(height: 96) } - .frame(height: 96) } + + Spacer() } - - Spacer() - } - .padding(.top, 36) - .padding(.bottom, 48) - .pokitPresentationCornerRadius() - .pokitPresentationBackground() - .presentationDragIndicator(.visible) - .readHeight() - .onPreferenceChange(HeightPreferenceKey.self) { height in - if let height { - self.height = height + .padding(.bottom, 48 - bottomSafeArea) + .padding(.top, 36) + .pokitPresentationCornerRadius() + .pokitPresentationBackground() + .presentationDragIndicator(.visible) + .readHeight() + .onPreferenceChange(HeightPreferenceKey.self) { height in + if let height { + self.height = height + } } + .presentationDetents([.height(self.height)]) } - .presentationDetents([.height(self.height)]) } } } diff --git a/Projects/App/Sources/MainTab/MainTabPath.swift b/Projects/App/Sources/MainTab/MainTabPath.swift index 21ec7cfd..68d1e33c 100644 --- a/Projects/App/Sources/MainTab/MainTabPath.swift +++ b/Projects/App/Sources/MainTab/MainTabPath.swift @@ -61,7 +61,7 @@ public extension MainTabFeature { switch action { /// - 네비게이션 바 `알림`버튼 눌렀을 때 case .pokit(.delegate(.alertButtonTapped)), - .remind(.delegate(.alertButtonTapped)), + .recommend(.delegate(.알림_버튼_눌렀을때)), .delegate(.알림함이동): state.isPushTapped = false state.path.append(.알림함(PokitAlertBoxFeature.State())) @@ -69,7 +69,7 @@ public extension MainTabFeature { /// - 네비게이션 바 `검색`버튼 눌렀을 때 case .pokit(.delegate(.searchButtonTapped)), - .remind(.delegate(.searchButtonTapped)): + .recommend(.delegate(.검색_버튼_눌렀을때)): state.path.append(.검색(PokitSearchFeature.State())) return .none @@ -83,23 +83,31 @@ public extension MainTabFeature { let .path(.element(_, action: .카테고리상세(.delegate(.포킷수정(category))))): state.path.append(.포킷추가및수정(PokitCategorySettingFeature.State( type: .수정, - categoryId: category.id, - categoryImage: category.categoryImage, - categoryName: category.categoryName + category: category ))) return .none + + case .recommend(.delegate(.컨텐츠_신고_API_반영)): + return .send(.inner(.링크팝업_활성화(.report(title: "신고가 완료되었습니다")))) /// - 포킷 `추가` 버튼 눌렀을 때 case .delegate(.포킷추가하기), .path(.element(_, action: .링크추가및수정(.delegate(.포킷추가하기)))), - .pokit(.delegate(.포킷추가_버튼_눌렀을때)): + .pokit(.delegate(.포킷추가_버튼_눌렀을때)), + .recommend(.delegate(.포킷_추가하기_버튼_눌렀을때)): state.path.append(.포킷추가및수정(PokitCategorySettingFeature.State(type: .추가))) return .none /// - 포킷 `추가` or `수정`이 성공적으로 `완료`되었을 때 case .path(.element(_, action: .포킷추가및수정(.delegate(.settingSuccess)))): state.path.removeLast() - guard let lastPath = state.path.last else { return .none } + guard let lastPath = state.path.last else { + switch state.selectedTab { + case .pokit: return .none + case .recommend: + return .send(.recommend(.delegate(.포킷_추가하기_완료))) + } + } switch lastPath { case .링크공유: state.path.removeLast() @@ -120,7 +128,6 @@ public extension MainTabFeature { /// - 링크 상세 case let .path(.element(_, action: .카테고리상세(.delegate(.contentItemTapped(content))))), let .pokit(.delegate(.contentDetailTapped(content))), - let .remind(.delegate(.링크상세(content))), let .path(.element(_, action: .링크목록(.delegate(.링크상세(content: content))))), let .path(.element(_, action: .검색(.delegate(.linkCardTapped(content: content))))): @@ -130,7 +137,6 @@ public extension MainTabFeature { /// - 링크상세 바텀시트에서 링크수정으로 이동 case let .contentDetail(.presented(.delegate(.editButtonTapped(id)))), let .pokit(.delegate(.링크수정하기(id))), - let .remind(.delegate(.링크수정(id))), let .path(.element(_, action: .카테고리상세(.delegate(.링크수정(id))))), let .path(.element(_, action: .링크목록(.delegate(.링크수정(id))))), let .path(.element(_, action: .검색(.delegate(.링크수정(id))))), @@ -148,8 +154,8 @@ public extension MainTabFeature { switch state.selectedTab { case .pokit: return .send(.pokit(.delegate(.미분류_카테고리_컨텐츠_조회))) - case .remind: - return .send(.remind(.delegate(.컨텐츠_상세보기_delegate_위임))) + case .recommend: + return .none } } switch lastPath { @@ -174,6 +180,11 @@ public extension MainTabFeature { state.path.append(.링크추가및수정(ContentSettingFeature.State(urlText: state.link))) state.link = nil return .none + + case let .path(.element(_, action: .카테고리상세(.delegate(.링크추가(categoryId))))): + state.categoryId = categoryId + state.path.append(.링크추가및수정(ContentSettingFeature.State())) + return .none /// - 링크추가 및 수정에서 저장하기 눌렀을 때 case let .path(.element(stackElementId, action: .링크추가및수정(.delegate(.저장하기_완료(contentId))))): @@ -188,6 +199,8 @@ public extension MainTabFeature { default: return .send(.inner(.링크팝업_활성화(.success(title: Constants.링크_저장_완료_문구))), animation: .pokitSpring) } + case .recommend(.delegate(.저장하기_완료)): + return .send(.inner(.링크팝업_활성화(.success(title: Constants.링크_저장_완료_문구))), animation: .pokitSpring) /// - 각 화면에서 링크 복사 감지했을 때 (링크 추가 및 수정 화면 제외) case let .path(.element(_, action: .알림함(.delegate(.linkCopyDetected(url))))), let .path(.element(_, action: .검색(.delegate(.linkCopyDetected(url))))), @@ -196,13 +209,9 @@ public extension MainTabFeature { let .path(.element(_, action: .포킷추가및수정(.delegate(.linkCopyDetected(url))))), let .path(.element(_, action: .링크목록(.delegate(.linkCopyDetected(url))))): return .run { send in await send(.inner(.linkCopySuccess(url)), animation: .pokitSpring) } - /// 링크목록 `안읽음` - case .remind(.delegate(.링크목록_안읽음)): - state.path.append(.링크목록(ContentListFeature.State(contentType: .unread))) - return .none - /// 링크목록 `즐겨찾기` - case .remind(.delegate(.링크목록_즐겨찾기)): - state.path.append(.링크목록(ContentListFeature.State(contentType: .favorite))) + /// 바텀메세지 + case let .pokit(.delegate(.linkPopup(text))): + state.linkPopup = .text(title: text) return .none case .path(.element(_, action: .설정(.delegate(.로그아웃)))): @@ -231,14 +240,24 @@ public extension MainTabFeature { return .none case let .path(.element(_, action: .링크공유(.delegate(.공유받은_카테고리_추가(sharedCategory))))): - state.path.append(.포킷추가및수정(PokitCategorySettingFeature.State( - type: .공유추가, - categoryId: sharedCategory.categoryId, + let category = BaseCategoryItem( + id: sharedCategory.categoryId, + userId: 0, + categoryName: sharedCategory.categoryName, categoryImage: BaseCategoryImage( imageId: sharedCategory.categoryImageId, imageURL: sharedCategory.categoryImageUrl ), - categoryName: sharedCategory.categoryName + contentCount: sharedCategory.contentCount, + createdAt: "", + openType: .공개, + keywordType: .default, + userCount: 0, + isFavorite: false + ) + state.path.append(.포킷추가및수정(PokitCategorySettingFeature.State( + type: .공유추가, + category: category ))) return .none case .path(.element(_, action: .알림함(.delegate(.alertBoxDismiss)))): diff --git a/Projects/App/Sources/Root/RootFeature.swift b/Projects/App/Sources/Root/RootFeature.swift index a31a69de..f372d349 100644 --- a/Projects/App/Sources/Root/RootFeature.swift +++ b/Projects/App/Sources/Root/RootFeature.swift @@ -8,7 +8,7 @@ import Foundation import ComposableArchitecture -import FeatureLogin +import FeatureIntro import CoreKit @Reducer diff --git a/Projects/App/Sources/Root/RootView.swift b/Projects/App/Sources/Root/RootView.swift index 748e5057..1d9d7d15 100644 --- a/Projects/App/Sources/Root/RootView.swift +++ b/Projects/App/Sources/Root/RootView.swift @@ -8,7 +8,7 @@ import SwiftUI import ComposableArchitecture -import FeatureLogin +import FeatureIntro import DSKit public struct RootView: View { diff --git a/Projects/CoreKit/Resources/.gitkeep b/Projects/CoreKit/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/CoreKit/Resources/Resource.swift b/Projects/CoreKit/Resources/Resource.swift deleted file mode 100644 index 3780fc99..00000000 --- a/Projects/CoreKit/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Source.stencil.swift -// Manifests -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/CoreKit/Sources/Data/Client/Keyboard/KeyboardClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Client/Keyboard/KeyboardClient+LiveKey.swift new file mode 100644 index 00000000..70cb7cd4 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/Keyboard/KeyboardClient+LiveKey.swift @@ -0,0 +1,39 @@ +// +// KeyboardClient+LiveKey.swift +// CoreKit +// +// Created by 김민호 on 3/31/25. +// + +import UIKit +import Combine +import Dependencies + +extension KeyboardClient: DependencyKey { + public static let liveValue: Self = { + .init( + isVisible: { + AsyncStream { continuation in + let notificationCenter = NotificationCenter.default + + let showObserver = notificationCenter + .publisher(for: UIResponder.keyboardWillShowNotification) + .map { _ in true } + + let hideObserver = notificationCenter + .publisher(for: UIResponder.keyboardWillHideNotification) + .map { _ in false } + + let cancellable = Publishers.Merge(showObserver, hideObserver) + .removeDuplicates() + .sink { isVisible in + continuation.yield(isVisible) + } + + continuation.onTermination = { _ in cancellable.cancel() } + } + } + ) + }() +} + diff --git a/Projects/CoreKit/Sources/Data/Client/Keyboard/KeyboardClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Client/Keyboard/KeyboardClient+TestKey.swift new file mode 100644 index 00000000..ecca5175 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/Keyboard/KeyboardClient+TestKey.swift @@ -0,0 +1,20 @@ +// +// KeyboardClient+TestKey.swift +// CoreKit +// +// Created by 김민호 on 3/31/25. +// + +import Foundation + +import Dependencies + +extension KeyboardClient: TestDependencyKey { + public static let previewValue = Self.noop +} + +extension KeyboardClient { + public static let noop = Self( + isVisible: { .finished } + ) +} diff --git a/Projects/CoreKit/Sources/Data/Client/Keyboard/KeyboardClient.swift b/Projects/CoreKit/Sources/Data/Client/Keyboard/KeyboardClient.swift new file mode 100644 index 00000000..c17cccd6 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/Client/Keyboard/KeyboardClient.swift @@ -0,0 +1,15 @@ +// +// KeyboardClient.swift +// CoreKit +// +// Created by 김민호 on 3/31/25. +// + +import Foundation + +import DependenciesMacros + +@DependencyClient +public struct KeyboardClient: Sendable { + public var isVisible: @Sendable () async -> AsyncStream = { .finished } +} diff --git a/Projects/CoreKit/Sources/Data/DTO/Base/BaseProfileImageResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Base/BaseProfileImageResponse.swift new file mode 100644 index 00000000..10455070 --- /dev/null +++ b/Projects/CoreKit/Sources/Data/DTO/Base/BaseProfileImageResponse.swift @@ -0,0 +1,17 @@ +// +// BaseProfileImageResponse.swift +// CoreKit +// +// Created by 김민호 on 2/25/25. +// + +import Util + +public struct BaseProfileImageResponse: Decodable { + public let id: Int + public let url: String +} + +extension BaseProfileImageResponse { + static let mock: Self = Self(id: 999, url: Constants.mockImageUrl) +} diff --git a/Projects/CoreKit/Sources/Data/DTO/Base/BaseUserResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Base/BaseUserResponse.swift index 3ca48cea..d787b2c4 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Base/BaseUserResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Base/BaseUserResponse.swift @@ -11,12 +11,14 @@ public struct BaseUserResponse: Decodable { public let id: Int public let email: String public let nickname: String + public let profileImage: BaseProfileImageResponse? } extension BaseUserResponse { public static var mock: Self = Self( id: 961222, email: "abcd@naver.com", - nickname: "뽀삐1" + nickname: "뽀삐1", + profileImage: BaseProfileImageResponse(id: 53211, url: "") ) } diff --git a/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift index 2568b90a..ea8d594f 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Base/ContentBaseResponse.swift @@ -18,6 +18,7 @@ public struct ContentBaseResponse: Decodable { public let createdAt: String public let isRead: Bool? public let isFavorite: Bool? + public let keyword: String? } extension ContentBaseResponse { @@ -25,17 +26,18 @@ extension ContentBaseResponse { Self( contentId: id, category: .init( - categoryId: 992, - categoryName: "미분류" + categoryId: 567, + categoryName: "신서유기" ), - data: "https://www.youtube.com/watch?v=wtSwdGJzQCQ", + data: "https://youtu.be/CIzKDrN7IpU?si=B0-7X7I_54VHAfkk", domain: "youtube", - title: "신서유기", + title: "[#샷추가] 거리 두기 철저하게 지키게 만드는 인물 퀴즈ㅋㅋㅋ어떤 음식을 뺄지 고민하지 마요..어차피 다 못 먹으니까요🤣 | #신서유기5 #Diggle", memo: nil, - thumbNail: "https://i.ytimg.com/vi/NnOC4_kH0ok/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDN6u6mTjbaVmRZ4biJS_aDq4uvAQ", - createdAt: "2024.08.08", + thumbNail: "https://i.ytimg.com/vi/CIzKDrN7IpU/maxresdefault.jpg", + createdAt: "2024.12.03", isRead: false, - isFavorite: true + isFavorite: true, + keyword: "예능" ) } } diff --git a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditRequest.swift b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditRequest.swift index d1288a88..182b2b36 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditRequest.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryEditRequest.swift @@ -10,9 +10,18 @@ import Foundation public struct CategoryEditRequest: Encodable { public let categoryName: String public let categoryImageId: Int + public let openType: String + public let keywordType: String - public init(categoryName: String, categoryImageId: Int) { + public init( + categoryName: String, + categoryImageId: Int, + openType: String, + keywordType: String + ) { self.categoryName = categoryName self.categoryImageId = categoryImageId + self.openType = openType + self.keywordType = keywordType } } diff --git a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryListInquiryResponse.swift b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryListInquiryResponse.swift index c1344341..f5a723b7 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Category/CategoryListInquiryResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Category/CategoryListInquiryResponse.swift @@ -25,6 +25,10 @@ public struct CategoryItemInquiryResponse: Decodable { public let categoryImage: CategoryImageResponse public let contentCount: Int public let createdAt: String + public let openType: String + public let keywordType: String + public let userCount: Int + public let isFavorite: Bool } /// Sort public struct ItemInquirySortResponse: Decodable { @@ -45,7 +49,11 @@ public extension CategoryItemInquiryResponse { imageUrl: Constants.mockImageUrl ), contentCount: 90, - createdAt: "" + createdAt: "", + openType: "PRIVATE", + keywordType: "스포츠/레저", + userCount: 0, + isFavorite: false ) } @@ -61,7 +69,11 @@ extension CategoryListInquiryResponse { imageUrl: Constants.mockImageUrl ), contentCount: 90, - createdAt: "" + createdAt: "", + openType: "PRIVATE", + keywordType: "스포츠/레저", + userCount: 0, + isFavorite: false ), CategoryItemInquiryResponse( categoryId: 2, @@ -72,7 +84,11 @@ extension CategoryListInquiryResponse { imageUrl: Constants.mockImageUrl ), contentCount: 90, - createdAt: "" + createdAt: "", + openType: "PUBLIC", + keywordType: "스포츠/레저", + userCount: 1, + isFavorite: false ), CategoryItemInquiryResponse( categoryId: 3, @@ -83,7 +99,11 @@ extension CategoryListInquiryResponse { imageUrl: Constants.mockImageUrl ), contentCount: 90, - createdAt: "" + createdAt: "", + openType: "PUBLIC", + keywordType: "스포츠/레저", + userCount: 5, + isFavorite: false ) ], page: 1, diff --git a/Projects/CoreKit/Sources/Data/DTO/Category/CopiedCategoryRequest.swift b/Projects/CoreKit/Sources/Data/DTO/Category/CopiedCategoryRequest.swift index d690e9a0..541482d1 100644 --- a/Projects/CoreKit/Sources/Data/DTO/Category/CopiedCategoryRequest.swift +++ b/Projects/CoreKit/Sources/Data/DTO/Category/CopiedCategoryRequest.swift @@ -11,14 +11,20 @@ public struct CopiedCategoryRequest: Encodable { public let originCategoryId: Int public var categoryName: String public var categoryImageId: Int + public let keyword: String + public let openType: String public init( originCategoryId: Int, categoryName: String, - categoryImageId: Int + categoryImageId: Int, + keyword: String, + openType: String ) { self.originCategoryId = originCategoryId self.categoryName = categoryName self.categoryImageId = categoryImageId + self.keyword = keyword + self.openType = openType } } diff --git a/Projects/CoreKit/Sources/Data/DTO/User/InterestRequest.swift b/Projects/CoreKit/Sources/Data/DTO/User/InterestRequest.swift new file mode 100644 index 00000000..c787f03e --- /dev/null +++ b/Projects/CoreKit/Sources/Data/DTO/User/InterestRequest.swift @@ -0,0 +1,16 @@ +// +// InterestRequest.swift +// CoreKit +// +// Created by 김도형 on 3/1/25. +// + +import Foundation + +public struct InterestRequest: Encodable { + public let interests: [String] + + public init(interests: [String]) { + self.interests = interests + } +} diff --git a/Projects/CoreKit/Sources/Data/DTO/User/InterestResponse.swift b/Projects/CoreKit/Sources/Data/DTO/User/InterestResponse.swift index 629cda7f..58d0628e 100644 --- a/Projects/CoreKit/Sources/Data/DTO/User/InterestResponse.swift +++ b/Projects/CoreKit/Sources/Data/DTO/User/InterestResponse.swift @@ -23,6 +23,14 @@ extension InterestResponse { Self(code: "code2", description: "산책"), Self(code: "code3", description: "프로그래밍"), Self(code: "code4", description: "여행"), - Self(code: "code5", description: "요리") + Self(code: "code5", description: "요리"), + Self(code: "code6", description: "스포츠/레저"), + Self(code: "code7", description: "기획/마케팅"), + Self(code: "code8", description: "쇼핑"), + Self(code: "code9", description: "경제/시사"), + Self(code: "code10", description: "영화/드라마"), + Self(code: "code11", description: "장소"), + Self(code: "code12", description: "인테리어"), + Self(code: "code13", description: "IT"), ] } diff --git a/Projects/CoreKit/Sources/Data/DTO/User/ProfileEditRequest.swift b/Projects/CoreKit/Sources/Data/DTO/User/ProfileEditRequest.swift new file mode 100644 index 00000000..aa7fac4f --- /dev/null +++ b/Projects/CoreKit/Sources/Data/DTO/User/ProfileEditRequest.swift @@ -0,0 +1,36 @@ +// +// ProfileEditRequest.swift +// CoreKit +// +// Created by 김민호 on 2/25/25. +// + +import Foundation +/// 프로필 수정 API Request +public struct ProfileEditRequest: Encodable { + public let profileImageId: Int? + public let nickname: String + + public init(profileImageId: Int?, nickname: String) { + self.profileImageId = profileImageId + self.nickname = nickname + } +} + +extension ProfileEditRequest { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(profileImageId, forKey: .profileImageId) + try container.encode(nickname, forKey: .nickname) + + if profileImageId == nil { + try container.encodeNil(forKey: .profileImageId) + } + } + + private enum CodingKeys: String, CodingKey { + case profileImageId + case nickname + } +} diff --git a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift index 58bb09c2..05fc56e0 100644 --- a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift @@ -21,8 +21,14 @@ extension CategoryClient: DependencyKey { 카테고리_수정: { id, model in try await provider.request(.카테고리_수정(categoryId: id, model: model)) }, - 카테고리_목록_조회: { model, categoryFilter in - try await provider.request(.카테고리_목록_조회(model: model, filterUncategorized: categoryFilter)) + 카테고리_목록_조회: { model, categoryFilter, favoriteFilter in + try await provider.request( + .카테고리_목록_조회( + model: model, + filterUncategorized: categoryFilter, + filterFavoriteCategorized: favoriteFilter + ) + ) }, 카테고리_생성: { model in try await provider.request(.카테고리생성(model: model)) diff --git a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+TestKey.swift index 6798f1b1..bcf23adf 100644 --- a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+TestKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+TestKey.swift @@ -12,7 +12,7 @@ extension CategoryClient: TestDependencyKey { Self( 카테고리_삭제: { _ in }, 카테고리_수정: { _, _ in .mock }, - 카테고리_목록_조회: { _, _ in .mock }, + 카테고리_목록_조회: { _, _, _ in .mock }, 카테고리_생성: { _ in .mock }, 카테고리_프로필_목록_조회: { CategoryImageResponse.mock }, 유저_카테고리_개수_조회: { .mock }, diff --git a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient.swift b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient.swift index 3bb41482..a147ba68 100644 --- a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient.swift @@ -14,7 +14,11 @@ public struct CategoryClient { _ categoryId: Int, _ model: CategoryEditRequest ) async throws -> CategoryEditResponse - public var 카테고리_목록_조회: @Sendable (_ model: BasePageableRequest, _ filterUncategorized: Bool) async throws -> CategoryListInquiryResponse + public var 카테고리_목록_조회: @Sendable ( + _ model: BasePageableRequest, + _ filterUncategorized: Bool, + _ filterFavoriteCategorized: Bool + ) async throws -> CategoryListInquiryResponse public var 카테고리_생성: @Sendable ( _ model: CategoryEditRequest ) async throws -> CategoryEditResponse diff --git a/Projects/CoreKit/Sources/Data/Network/Category/CategoryEndpoint.swift b/Projects/CoreKit/Sources/Data/Network/Category/CategoryEndpoint.swift index b78f1cc9..dbfe8dc7 100644 --- a/Projects/CoreKit/Sources/Data/Network/Category/CategoryEndpoint.swift +++ b/Projects/CoreKit/Sources/Data/Network/Category/CategoryEndpoint.swift @@ -13,7 +13,7 @@ import Moya public enum CategoryEndpoint { case 카테고리_삭제(categoryId: Int) case 카테고리_수정(categoryId: Int, model: CategoryEditRequest) - case 카테고리_목록_조회(model: BasePageableRequest, filterUncategorized: Bool) + case 카테고리_목록_조회(model: BasePageableRequest, filterUncategorized: Bool, filterFavoriteCategorized: Bool) case 카테고리생성(model: CategoryEditRequest) case 카테고리_프로필_목록_조회 case 유저_카테고리_개수_조회 @@ -25,7 +25,13 @@ public enum CategoryEndpoint { extension CategoryEndpoint: TargetType { public var baseURL: URL { - return Constants.serverURL.appendingPathComponent(Constants.categoryPath, conformingTo: .url) + switch self { + case .카테고리_목록_조회, .카테고리생성, .카테고리_수정, .공유받은_카테고리_저장: + return Constants.serverURL.appendingPathComponent(Constants.categoryPathV2, conformingTo: .url) + + default: + return Constants.serverURL.appendingPathComponent(Constants.categoryPath, conformingTo: .url) + } } public var path: String { @@ -77,13 +83,14 @@ extension CategoryEndpoint: TargetType { return .requestPlain case let .카테고리_수정(_, model): return .requestJSONEncodable(model) - case let .카테고리_목록_조회(model, categorized): + case let .카테고리_목록_조회(model, categorized, favoriteCategorized): return .requestParameters( parameters: [ "page": model.page, "size": model.size, "sort": model.sort.map { String($0) }.joined(separator: ","), - "filterUncategorized": categorized + "filterUncategorized": categorized, + "filterFavorite": favoriteCategorized ], encoding: URLEncoding.default ) diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift index a506f1de..27db10d0 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+LiveKey.swift @@ -61,6 +61,12 @@ extension ContentClient: DependencyKey { }, 미분류_링크_삭제: { model in try await provider.requestNoBody(.미분류_링크_삭제(model: model)) + }, + 추천_컨텐츠_조회: { pageable, keyword in + try await provider.request(.추천_컨텐츠_조회(pageable: pageable, keyword: keyword)) + }, + 컨텐츠_신고: { id in + try await provider.requestNoBody(.컨텐츠_신고(contentId: id)) } ) }() diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift index f9943f3e..25a4a976 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient+TestKey.swift @@ -20,7 +20,9 @@ extension ContentClient: TestDependencyKey { 컨텐츠_검색: { _, _ in .mock }, 썸네일_수정: { _, _ in }, 미분류_링크_포킷_이동: { _ in }, - 미분류_링크_삭제: { _ in } + 미분류_링크_삭제: { _ in }, + 추천_컨텐츠_조회: { _, _ in .mock }, + 컨텐츠_신고: { _ in } ) }() } diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift index 9c65dc5a..211468b3 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentClient.swift @@ -50,5 +50,12 @@ public struct ContentClient { public var 미분류_링크_삭제: @Sendable ( _ model: ContentDeleteRequest ) async throws -> Void + public var 추천_컨텐츠_조회: @Sendable ( + _ pageable: BasePageableRequest, + _ keyword: String? + ) async throws -> ContentListInquiryResponse + public var 컨텐츠_신고: @Sendable ( + _ contentId: Int + ) async throws -> Void } diff --git a/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift b/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift index 739aa56a..a0475b88 100644 --- a/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift +++ b/Projects/CoreKit/Sources/Data/Network/Content/ContentEndpoint.swift @@ -30,6 +30,11 @@ public enum ContentEndpoint { case 썸네일_수정(contentId: String, model: ThumbnailRequest) case 미분류_링크_포킷_이동(model: ContentMoveRequest) case 미분류_링크_삭제(model: ContentDeleteRequest) + case 추천_컨텐츠_조회( + pageable: BasePageableRequest, + keyword: String? + ) + case 컨텐츠_신고(contentId: Int) } extension ContentEndpoint: TargetType { @@ -63,6 +68,10 @@ extension ContentEndpoint: TargetType { return "" case .미분류_링크_삭제: return "/uncategorized" + case .추천_컨텐츠_조회: + return "/recommended" + case let .컨텐츠_신고(contentId): + return "report/\(contentId)" } } @@ -75,7 +84,8 @@ extension ContentEndpoint: TargetType { case .컨텐츠_상세_조회, .즐겨찾기, - .컨텐츠_추가: + .컨텐츠_추가, + .컨텐츠_신고: return .post case .컨텐츠_수정, @@ -85,7 +95,8 @@ extension ContentEndpoint: TargetType { case .카태고리_내_컨텐츠_목록_조회, .미분류_카테고리_컨텐츠_조회, - .컨텐츠_검색: + .컨텐츠_검색, + .추천_컨텐츠_조회: return .get } } @@ -126,6 +137,19 @@ extension ContentEndpoint: TargetType { ], encoding: URLEncoding.default ) + case let .추천_컨텐츠_조회(pageable, keyword): + var parameters: [String: Any] = [ + "page": pageable.page, + "size": pageable.size, + "sort": pageable.sort.map { String($0) }.joined(separator: ",") + ] + if let keyword { + parameters["keyword"] = keyword + } + return .requestParameters( + parameters: parameters, + encoding: URLEncoding.default + ) case let .컨텐츠_검색(pageable, condition): return .requestParameters( parameters: [ @@ -149,6 +173,8 @@ extension ContentEndpoint: TargetType { case let .미분류_링크_삭제(model): return .requestJSONEncodable(model) + case .컨텐츠_신고: + return .requestPlain } } diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift index a268c605..900ec4d1 100644 --- a/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift @@ -15,6 +15,9 @@ extension UserClient: DependencyKey { let provider = MoyaProvider.build() return Self( + 프로필_수정: { model in + try await provider.request(.프로필_수정(model: model)) + }, 닉네임_수정: { model in try await provider.request(.닉네임_수정(model: model)) }, @@ -32,6 +35,15 @@ extension UserClient: DependencyKey { }, fcm_토큰_저장: { model in try await provider.request(.fcm_토큰_저장(model: model)) + }, + 프로필_이미지_목록_조회: { + try await provider.request(.프로필_이미지_목록_조회) + }, + 유저_관심사_목록_조회: { + try await provider.request(.유저_관심사_목록_조회) + }, + 관심사_수정: { model in + try await provider.requestNoBody(.관심사_수정(model: model)) } ) }() diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift index 2f3d37c8..7383a14b 100644 --- a/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift +++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient+TestKey.swift @@ -12,12 +12,16 @@ import Dependencies extension UserClient: TestDependencyKey { public static let previewValue: Self = { Self( + 프로필_수정: { _ in .mock }, 닉네임_수정: { _ in .mock }, 회원등록: { _ in .mock }, 닉네임_중복_체크: { _ in .mock }, 관심사_목록_조회: { InterestResponse.mock }, 닉네임_조회: { .mock }, - fcm_토큰_저장: { _ in .mock } + fcm_토큰_저장: { _ in .mock }, + 프로필_이미지_목록_조회: { [.mock] }, + 유저_관심사_목록_조회: { InterestResponse.mock }, + 관심사_수정: { _ in } ) }() } diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift index 7ccb2149..b0207386 100644 --- a/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift +++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient.swift @@ -9,10 +9,14 @@ import DependenciesMacros @DependencyClient public struct UserClient { + public var 프로필_수정: @Sendable (_ model: ProfileEditRequest) async throws -> BaseUserResponse public var 닉네임_수정: @Sendable (_ model: NicknameEditRequest) async throws -> BaseUserResponse public var 회원등록: @Sendable (_ model: SignupRequest) async throws -> BaseUserResponse public var 닉네임_중복_체크: @Sendable (_ nickname: String) async throws -> NicknameCheckResponse public var 관심사_목록_조회: @Sendable () async throws -> [InterestResponse] public var 닉네임_조회: @Sendable () async throws -> BaseUserResponse public var fcm_토큰_저장: @Sendable (_ model: FCMRequest) async throws -> FCMResponse + public var 프로필_이미지_목록_조회: @Sendable () async throws -> [BaseProfileImageResponse] + public var 유저_관심사_목록_조회: @Sendable () async throws -> [InterestResponse] + public var 관심사_수정: @Sendable (_ model: InterestRequest) async throws -> Void } diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserEndpoint.swift b/Projects/CoreKit/Sources/Data/Network/User/UserEndpoint.swift index d928fb2b..da003b7d 100644 --- a/Projects/CoreKit/Sources/Data/Network/User/UserEndpoint.swift +++ b/Projects/CoreKit/Sources/Data/Network/User/UserEndpoint.swift @@ -11,12 +11,16 @@ import Util import Moya /// 컨텐츠 전용 Endpont public enum UserEndpoint { + case 프로필_수정(model: ProfileEditRequest) case 닉네임_수정(model: NicknameEditRequest) case 회원등록(model: SignupRequest) case 닉네임_중복_체크(nickname: String) case 관심사_목록_조회 case 닉네임_조회 case fcm_토큰_저장(model: FCMRequest) + case 프로필_이미지_목록_조회 + case 유저_관심사_목록_조회 + case 관심사_수정(model: InterestRequest) } extension UserEndpoint: TargetType { @@ -26,6 +30,8 @@ extension UserEndpoint: TargetType { public var path: String { switch self { + case .프로필_수정: + return "" case .닉네임_수정, .닉네임_조회: return "/nickname" case .회원등록: @@ -36,12 +42,18 @@ extension UserEndpoint: TargetType { return "/interests" case .fcm_토큰_저장: return "/fcm" + case .프로필_이미지_목록_조회: + return "/profileImage" + case .유저_관심사_목록_조회, .관심사_수정: + return "/myinterests" } } public var method: Moya.Method { switch self { - case .닉네임_수정: + case .닉네임_수정, + .관심사_수정, + .프로필_수정: return .put case .회원등록, @@ -50,22 +62,30 @@ extension UserEndpoint: TargetType { case .닉네임_중복_체크, .관심사_목록_조회, - .닉네임_조회: + .닉네임_조회, + .프로필_이미지_목록_조회, + .유저_관심사_목록_조회: return .get } } public var task: Moya.Task { switch self { + case let .프로필_수정(model): + return .requestJSONEncodable(model) case let .닉네임_수정(model): return .requestJSONEncodable(model) case let .회원등록(model): return .requestJSONEncodable(model) case let .fcm_토큰_저장(model): return .requestJSONEncodable(model) + case let .관심사_수정(model): + return .requestJSONEncodable(model) case .닉네임_중복_체크, .관심사_목록_조회, - .닉네임_조회: + .닉네임_조회, + .프로필_이미지_목록_조회, + .유저_관심사_목록_조회: return .requestPlain } } diff --git a/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/Contents.json b/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/Contents.json index b7570c35..50c8efb9 100644 --- a/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/Contents.json +++ b/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/Contents.json @@ -1,15 +1,17 @@ { "images" : [ { - "filename" : "character_empty.png", + "filename" : "empty_graphic 1.png", "idiom" : "universal", "scale" : "1x" }, { + "filename" : "empty_graphic 1@2x.png", "idiom" : "universal", "scale" : "2x" }, { + "filename" : "empty_graphic 1@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/character_empty.png b/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/character_empty.png deleted file mode 100644 index 98a7d924..00000000 Binary files a/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/character_empty.png and /dev/null differ diff --git a/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/empty_graphic 1.png b/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/empty_graphic 1.png new file mode 100644 index 00000000..506bddbd Binary files /dev/null and b/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/empty_graphic 1.png differ diff --git a/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/empty_graphic 1@2x.png b/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/empty_graphic 1@2x.png new file mode 100644 index 00000000..141b705e Binary files /dev/null and b/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/empty_graphic 1@2x.png differ diff --git a/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/empty_graphic 1@3x.png b/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/empty_graphic 1@3x.png new file mode 100644 index 00000000..2c40e880 Binary files /dev/null and b/Projects/DSKit/Resources/Assets.xcassets/character_empty.imageset/empty_graphic 1@3x.png differ diff --git a/Projects/DSKit/Resources/Assets.xcassets/icon_tack.imageset/Contents.json b/Projects/DSKit/Resources/Assets.xcassets/icon_tack.imageset/Contents.json new file mode 100644 index 00000000..59512e53 --- /dev/null +++ b/Projects/DSKit/Resources/Assets.xcassets/icon_tack.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tack.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DSKit/Resources/Assets.xcassets/icon_tack.imageset/tack.svg b/Projects/DSKit/Resources/Assets.xcassets/icon_tack.imageset/tack.svg new file mode 100644 index 00000000..4125fe87 --- /dev/null +++ b/Projects/DSKit/Resources/Assets.xcassets/icon_tack.imageset/tack.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/DSKit/Resources/Assets.xcassets/image_profile.imageset/Contents.json b/Projects/DSKit/Resources/Assets.xcassets/image_profile.imageset/Contents.json new file mode 100644 index 00000000..ca32e427 --- /dev/null +++ b/Projects/DSKit/Resources/Assets.xcassets/image_profile.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "image_profile.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DSKit/Resources/Assets.xcassets/image_profile.imageset/image_profile.svg b/Projects/DSKit/Resources/Assets.xcassets/image_profile.imageset/image_profile.svg new file mode 100644 index 00000000..23f3e969 --- /dev/null +++ b/Projects/DSKit/Resources/Assets.xcassets/image_profile.imageset/image_profile.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/Contents.json b/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/Contents.json new file mode 100644 index 00000000..8720ae1e --- /dev/null +++ b/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "unpokited.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "unpokited@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "unpokited@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/unpokited.png b/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/unpokited.png new file mode 100644 index 00000000..291150af Binary files /dev/null and b/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/unpokited.png differ diff --git a/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/unpokited@2x.png b/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/unpokited@2x.png new file mode 100644 index 00000000..bdce9ee1 Binary files /dev/null and b/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/unpokited@2x.png differ diff --git a/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/unpokited@3x.png b/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/unpokited@3x.png new file mode 100644 index 00000000..0e67d18a Binary files /dev/null and b/Projects/DSKit/Resources/Assets.xcassets/unpokited.imageset/unpokited@3x.png differ diff --git a/Projects/DSKit/Sources/Components/PokitAlert.swift b/Projects/DSKit/Sources/Components/PokitAlert.swift index ea3c7896..2153bc1e 100644 --- a/Projects/DSKit/Sources/Components/PokitAlert.swift +++ b/Projects/DSKit/Sources/Components/PokitAlert.swift @@ -31,54 +31,58 @@ public struct PokitAlert: View { } public var body: some View { - VStack(spacing: 0) { - VStack(spacing: 8) { - title + GeometryReader { proxy in + let bottomSafeArea = proxy.safeAreaInsets.bottom + VStack(spacing: 0) { + VStack(spacing: 8) { + title + + if message != nil { + messageLabel + } + } + .padding(.top, 36) + .padding(.bottom, 20) - if message != nil { - messageLabel + PokitBottomSwitchRadio { + PokitPartSwitchRadio( + labelText: "취소", + selection: .constant(false), + to: true, + style: .stroke + ) { + cancelAction?() + dismiss() + } + .background() + + PokitPartSwitchRadio( + labelText: confirmText, + selection: .constant(true), + to: true, + style: .filled, + action: action + ) + .background() } + .pokitMaxWidth() } - .padding(.top, 36) - .padding(.bottom, 20) - - PokitBottomSwitchRadio { - PokitPartSwitchRadio( - labelText: "취소", - selection: .constant(false), - to: true, - style: .stroke - ) { - cancelAction?() - dismiss() + .pokitPresentationBackground() + .pokitPresentationCornerRadius() + .presentationDragIndicator(.visible) + .padding(.bottom, 36 - bottomSafeArea) + .readHeight() + .onPreferenceChange(HeightPreferenceKey.self) { height in + if let height { + self.height = height } - .background() - - PokitPartSwitchRadio( - labelText: confirmText, - selection: .constant(true), - to: true, - style: .filled, - action: action - ) - .background() } - .pokitMaxWidth() - } - .pokitPresentationBackground() - .pokitPresentationCornerRadius() - .presentationDragIndicator(.visible) - .readHeight() - .onPreferenceChange(HeightPreferenceKey.self) { height in - if let height { - self.height = height + .presentationDetents([.height(self.height)]) + .onAppear { + UINotificationFeedbackGenerator() + .notificationOccurred(.warning) } } - .presentationDetents([.height(self.height)]) - .onAppear { - UINotificationFeedbackGenerator() - .notificationOccurred(.warning) - } } private var title: some View { diff --git a/Projects/DSKit/Sources/Components/PokitBottomButton.swift b/Projects/DSKit/Sources/Components/PokitBottomButton.swift index 229c08af..bdfcef05 100644 --- a/Projects/DSKit/Sources/Components/PokitBottomButton.swift +++ b/Projects/DSKit/Sources/Components/PokitBottomButton.swift @@ -14,17 +14,36 @@ public struct PokitBottomButton: View { private let labelText: String private let state: PokitButtonStyle.State private let size: PokitButtonStyle.Size = .large + private let isGradient: Bool + private let isKeyboardVisible: Bool private let action: () -> Void - public init( + private init( _ labelText: String, state: PokitButtonStyle.State, - isLoading: Binding = .constant(false), + isLoading: Binding, + isGradient: Bool, + isKeyboardVisible: Bool, action: @escaping () -> Void ) { self.labelText = labelText self.state = state self._isLoading = isLoading + self.isGradient = isGradient + self.isKeyboardVisible = isKeyboardVisible + self.action = action + } + + public init( + _ labelText: String, + state: PokitButtonStyle.State, + action: @escaping () -> Void + ) { + self.labelText = labelText + self.state = state + self._isLoading = .constant(false) + self.isGradient = false + self.isKeyboardVisible = false self.action = action } @@ -37,7 +56,17 @@ public struct PokitBottomButton: View { } .disabled(state == .disable) .padding(.top, 16) - .padding(.bottom, 36) + .padding(.bottom, isKeyboardVisible ? 10 : 36) + .background(if: isGradient) { + LinearGradient( + stops: [ + Gradient.Stop(color: .pokit(.bg(.base)).opacity(0), location: 0.00), + Gradient.Stop(color: .pokit(.bg(.base)), location: 0.20), + ], + startPoint: UnitPoint(x: 0.5, y: 0), + endPoint: UnitPoint(x: 0.5, y: 1) + ) + } } private var label: some View { @@ -71,7 +100,41 @@ public struct PokitBottomButton: View { .stroke(self.state.backgroundStrokeColor, lineWidth: 1) } } + .padding(.horizontal, isGradient ? 20 : 0) .animation(.pokitDissolve, value: self.state) } + + public func keyboardAnchor(_ isKeyboardVisible: Bool) -> Self { + PokitBottomButton( + self.labelText, + state: self.state, + isLoading: self.$isLoading, + isGradient: self.isGradient, + isKeyboardVisible: isKeyboardVisible, + action: self.action + ) + } + + public func gradientBackground() -> Self { + PokitBottomButton( + self.labelText, + state: self.state, + isLoading: self.$isLoading, + isGradient: true, + isKeyboardVisible: self.isKeyboardVisible, + action: self.action + ) + } + + public func loading(_ isLoading: Binding) -> Self { + PokitBottomButton( + self.labelText, + state: self.state, + isLoading: isLoading, + isGradient: self.isGradient, + isKeyboardVisible: self.isKeyboardVisible, + action: self.action + ) + } } diff --git a/Projects/DSKit/Sources/Components/PokitBottomSheet.swift b/Projects/DSKit/Sources/Components/PokitBottomSheet.swift index 28765b50..b3295556 100644 --- a/Projects/DSKit/Sources/Components/PokitBottomSheet.swift +++ b/Projects/DSKit/Sources/Components/PokitBottomSheet.swift @@ -8,13 +8,14 @@ import SwiftUI public struct PokitBottomSheet: View { + @State + private var height: CGFloat private let items: [Item] - private let height: CGFloat private let delegateSend: ((PokitBottomSheet.Delegate) -> Void)? public init( items: [Item], - height: CGFloat, + height: CGFloat = 0, delegateSend: ((PokitBottomSheet.Delegate) -> Void)? ) { self.items = items @@ -30,6 +31,16 @@ public struct PokitBottomSheet: View { .presentationDetents([.height(height)]) .pokitPresentationCornerRadius() .pokitPresentationBackground() + .readHeight() + .onPreferenceChange(HeightPreferenceKey.self) { height in + if let height { + print("height:", height) + self.height = height + } + } + .ignoresSafeArea(.all) + .padding(.top, 12) + .padding(.bottom, -20) } @ViewBuilder @@ -120,3 +131,18 @@ public extension PokitBottomSheet { } } +@available(iOS 18.0, *) +#Preview { + @Previewable + @State var isPresented: Bool = true + + ZStack { + Color.green.ignoresSafeArea() + } + .sheet(isPresented: $isPresented) { + PokitBottomSheet( + items: [.share, .edit, .delete], + delegateSend: { _ in } + ) + } +} diff --git a/Projects/DSKit/Sources/Components/PokitBottomSwitchRadio.swift b/Projects/DSKit/Sources/Components/PokitBottomSwitchRadio.swift index 07a54ce5..ad65071d 100644 --- a/Projects/DSKit/Sources/Components/PokitBottomSwitchRadio.swift +++ b/Projects/DSKit/Sources/Components/PokitBottomSwitchRadio.swift @@ -20,7 +20,7 @@ public struct PokitBottomSwitchRadio: View { } .padding(.horizontal, 20) .padding(.top, 16) - .padding(.bottom, 28) +// .padding(.bottom, 28) .background(.pokit(.bg(.base))) } } diff --git a/Projects/DSKit/Sources/Components/PokitCalendar.swift b/Projects/DSKit/Sources/Components/PokitCalendar.swift index f3e95270..8a4e2458 100644 --- a/Projects/DSKit/Sources/Components/PokitCalendar.swift +++ b/Projects/DSKit/Sources/Components/PokitCalendar.swift @@ -181,6 +181,7 @@ public struct PokitCalendar: View { let isSelected = isStartDate || isEndDate let backgoundColor: Color = .pokit(.bg(.brand)).opacity(isSelected ? 1 : isContains ? 0.2 : 0) let day = calendar.component(.day, from: date) + let isFuture = date > .now Button { dayButtonTapped( @@ -192,13 +193,14 @@ public struct PokitCalendar: View { } label: { Text("\(day)") .pokitFont(.b1(.m)) - .foregroundStyle(isCurrentMonth ? textColor : .pokit(.text(.tertiary))) + .foregroundStyle(isCurrentMonth && !isFuture ? textColor : .pokit(.text(.tertiary))) .frame(width: width, height: width) .background { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(backgoundColor) } } + .disabled(isFuture) } private func dates(_ date: Date) -> [Date] { diff --git a/Projects/DSKit/Sources/Components/PokitCard.swift b/Projects/DSKit/Sources/Components/PokitCard.swift index 5d019f8f..c073201a 100644 --- a/Projects/DSKit/Sources/Components/PokitCard.swift +++ b/Projects/DSKit/Sources/Components/PokitCard.swift @@ -10,7 +10,7 @@ import SwiftUI import Util import NukeUI -public struct PokitCard: View { +public struct PokitCard: View { private let category: Item private let action: () -> Void private let kebabAction: () -> Void @@ -66,10 +66,22 @@ public struct PokitCard: View { .frame(height: 152) } + @ViewBuilder private var title: some View { - Text(category.categoryName) - .pokitFont(.b1(.b)) - .foregroundStyle(.pokit(.text(.primary))) + let isPrivate = category.openType == .비공개 + + HStack(spacing: isPrivate ? 2 : 0) { + if isPrivate { + Image(.icon(.lock)) + .resizable() + .frame(width: 18, height: 18) + .padding(.vertical, 3) + .foregroundStyle(.pokit(.icon(.tertiary))) + } + Text(category.categoryName) + .pokitFont(.b1(.b)) + .foregroundStyle(.pokit(.text(.primary))) + } } private var kebabButton: some View { @@ -81,11 +93,25 @@ public struct PokitCard: View { } } + @ViewBuilder private var subTitle: some View { - Text("링크 \(category.contentCount)개") - .pokitFont(.detail2) - .foregroundStyle(.pokit(.text(.tertiary))) - .contentTransition(.numericText()) + let isShared = category.userCount > 1 + + HStack(spacing: 2) { + Text("링크 \(category.contentCount)개") + .contentTransition(.numericText()) + + if isShared { + Text("•") + .pokitFont(.detail2) + Image(.iconMember) + .resizable() + .frame(width: 16, height: 16) + Text("\(category.userCount)명") + } + } + .pokitFont(.detail2) + .foregroundStyle(.pokit(.text(.tertiary))) } @MainActor diff --git a/Projects/DSKit/Sources/Components/PokitCaution.swift b/Projects/DSKit/Sources/Components/PokitCaution.swift index fea55d24..e2ced148 100644 --- a/Projects/DSKit/Sources/Components/PokitCaution.swift +++ b/Projects/DSKit/Sources/Components/PokitCaution.swift @@ -10,19 +10,22 @@ import SwiftUI public enum CautionType { case 카테고리없음 case 미분류_링크없음 + case 포킷상세_링크없음 case 링크없음 case 즐겨찾기_링크없음 case 링크부족 case 알림없음 + case 추천_링크없음 var image: PokitImage.Character { switch self { - case .카테고리없음, .링크없음, .즐겨찾기_링크없음, .미분류_링크없음: - return .empty case .링크부족: return .sad + case .알림없음: return .pooki + + default: return .empty } } @@ -32,7 +35,7 @@ public enum CautionType { return "저장된 포킷이 없어요!" case .미분류_링크없음: return "미분류 링크가 없어요!" - case .링크없음: + case .링크없음, .포킷상세_링크없음: return "저장된 링크가 없어요!" case .즐겨찾기_링크없음: return "즐겨찾기 링크가 없어요!" @@ -40,6 +43,8 @@ public enum CautionType { return "링크가 부족해요!" case .알림없음: return "알림이 없어요" + case .추천_링크없음: + return "아직 추천된 링크가 없어요!" } } @@ -49,6 +54,8 @@ public enum CautionType { return "포킷을 생성해 링크를 저장해보세요" case .미분류_링크없음: return "링크를 포킷에 깔끔하게 분류하셨어요" + case .포킷상세_링크없음: + return "포킷에 링크를 저장해보세요" case .링크없음: return "다양한 링크를 한 곳에 저장해보세요" case .즐겨찾기_링크없음: @@ -57,6 +64,8 @@ public enum CautionType { return "링크를 5개 이상 저장하고 추천을 받아보세요" case .알림없음: return "리마인드 알림을 설정하세요" + case .추천_링크없음: + return "다른 사용자들이 링크를 저장하면\n추천해드릴게요" } } @@ -64,7 +73,7 @@ public enum CautionType { switch self { case .카테고리없음: return "포킷 추가하기" - case .미분류_링크없음: + case .미분류_링크없음, .포킷상세_링크없음: return "링크 추가하기" default: return nil } @@ -92,7 +101,7 @@ public struct PokitCaution: View { .resizable() .aspectRatio(contentMode: .fill) .frame(width: 180, height: 180) - .padding(.bottom, 16) + .padding(.bottom, 8) Text(type.title) .pokitFont(.title2) @@ -100,9 +109,10 @@ public struct PokitCaution: View { .padding(.bottom, 8) Text(type.message) + .multilineTextAlignment(.center) .pokitFont(.b2(.m)) .foregroundStyle(.pokit(.text(.secondary))) - .padding(.bottom, 16) + .padding(.bottom, 20) if let action, let actionTitle = type.actionTitle { @@ -118,6 +128,9 @@ public struct PokitCaution: View { Spacer() } + .padding(.bottom, 80) + .frame(maxHeight: .infinity) + .padding(.bottom, 92) } } diff --git a/Projects/DSKit/Sources/Components/PokitDeleteBottomSheet.swift b/Projects/DSKit/Sources/Components/PokitDeleteBottomSheet.swift index c8ccf28c..0a7f368d 100644 --- a/Projects/DSKit/Sources/Components/PokitDeleteBottomSheet.swift +++ b/Projects/DSKit/Sources/Components/PokitDeleteBottomSheet.swift @@ -22,51 +22,56 @@ public struct PokitDeleteBottomSheet: View { } public var body: some View { - VStack(spacing: 0) { - /// - 텍스트 영역 - VStack(spacing: 8) { - Text(type.sheetTitle) - .foregroundStyle(.pokit(.text(.primary))) - .pokitFont(.title2) - Text(type.sheetContents) - .fixedSize(horizontal: false, vertical: true) - .multilineTextAlignment(.center) - .foregroundStyle(.pokit(.text(.secondary))) - .pokitFont(.b2(.m)) + GeometryReader { proxy in + let bottomSafeArea = proxy.safeAreaInsets.bottom + + VStack(spacing: 0) { + /// - 텍스트 영역 + VStack(spacing: 8) { + Text(type.sheetTitle) + .foregroundStyle(.pokit(.text(.primary))) + .pokitFont(.title2) + Text(type.sheetContents) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.center) + .foregroundStyle(.pokit(.text(.secondary))) + .pokitFont(.b2(.m)) + } + .padding(.top, 36) + .padding(.bottom, 20) + /// - 취소 / 삭제 버튼 영역 + HStack(spacing: 8) { + PokitBottomButton( + "취소", + state: .default(.primary), + action: { delegateSend?(.cancelButtonTapped) } + ) + + PokitBottomButton( + "삭제", + state: .filled(.primary), + action: { delegateSend?(.deleteButtonTapped) } + ) + } } - .padding(.top, 36) - .padding(.bottom, 20) - /// - 취소 / 삭제 버튼 영역 - HStack(spacing: 8) { - PokitBottomButton( - "취소", - state: .default(.primary), - action: { delegateSend?(.cancelButtonTapped) } - ) - - PokitBottomButton( - "삭제", - state: .filled(.primary), - action: { delegateSend?(.deleteButtonTapped) } - ) + .padding(.horizontal, 20) + .padding(.bottom, 36 - bottomSafeArea) + .background(.white) + .pokitPresentationCornerRadius() + .pokitPresentationBackground() + .presentationDragIndicator(.visible) + .readHeight() + .onPreferenceChange(HeightPreferenceKey.self) { height in + if let height { + self.height = height + } } - } - .padding(.horizontal, 20) - .background(.white) - .pokitPresentationCornerRadius() - .pokitPresentationBackground() - .presentationDragIndicator(.visible) - .readHeight() - .onPreferenceChange(HeightPreferenceKey.self) { height in - if let height { - self.height = height + .presentationDetents([.height(self.height)]) + .onAppear { + UINotificationFeedbackGenerator() + .notificationOccurred(.warning) } } - .presentationDetents([.height(self.height)]) - .onAppear { - UINotificationFeedbackGenerator() - .notificationOccurred(.warning) - } } } //MARK: - Delegate diff --git a/Projects/DSKit/Sources/Components/PokitFlowLayout.swift b/Projects/DSKit/Sources/Components/PokitFlowLayout.swift index 98a7042a..e69a454f 100644 --- a/Projects/DSKit/Sources/Components/PokitFlowLayout.swift +++ b/Projects/DSKit/Sources/Components/PokitFlowLayout.swift @@ -20,44 +20,64 @@ public struct PokitFlowLayout: Layout { } public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - var width: CGFloat = 0 - var height: CGFloat = 0 + var totalWidth: CGFloat = 0 + var totalHeight: CGFloat = 0 var rowWidth: CGFloat = 0 var rowHeight: CGFloat = 0 - - for subview in subviews { - let size = subview.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) - if rowWidth + size.width > proposal.width ?? .infinity { - height += rowHeight + rowSpacing - width = max(width, rowWidth) - rowWidth = 0 - rowHeight = 0 + let maxWidth = proposal.width ?? CGFloat.infinity + + for (_, subview) in subviews.enumerated() { + let subviewSize = subview.sizeThatFits(ProposedViewSize(width: maxWidth, height: nil)) + let itemWidth = subviewSize.width + let itemHeight = subviewSize.height + + if rowWidth > 0 && (rowWidth + colSpacing + itemWidth) > maxWidth { + // 현재 행 마무리 + totalWidth = max(totalWidth, rowWidth) + totalHeight += rowHeight + rowSpacing + // 새로운 행 시작 + rowWidth = itemWidth + rowHeight = itemHeight + } else { + if rowWidth > 0 { + rowWidth += colSpacing + } + rowWidth += itemWidth + rowHeight = max(rowHeight, itemHeight) } - rowWidth += size.width + colSpacing - rowHeight = max(rowHeight, size.height) } - - height += rowHeight - width = max(width, rowWidth) - - return CGSize(width: width, height: height) + + // 마지막 행 높이 추가 + totalWidth = max(totalWidth, rowWidth) + totalHeight += rowHeight + rowSpacing + + return CGSize(width: totalWidth, height: totalHeight) } - + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - var x: CGFloat = bounds.minX - var y: CGFloat = bounds.minY + var x = bounds.minX + var y = bounds.minY var rowHeight: CGFloat = 0 - - for subview in subviews { - let size = subview.sizeThatFits(ProposedViewSize(width: bounds.width, height: nil)) - if x + size.width > bounds.width { + let maxX = bounds.maxX + + for (_, subview) in subviews.enumerated() { + let subviewSize = subview.sizeThatFits(ProposedViewSize(width: bounds.width, height: nil)) + let itemWidth = subviewSize.width + let itemHeight = subviewSize.height + + if x > bounds.minX - 1 && (x + colSpacing + itemWidth) > maxX + 1 { + // 현재 행 마무리하고 다음 행 시작 x = bounds.minX y += rowHeight + rowSpacing rowHeight = 0 } - subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) - x += size.width + colSpacing - rowHeight = max(rowHeight, size.height) + if x > bounds.minX { + x += colSpacing + } + // 아이템 배치 + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(subviewSize)) + x += itemWidth + rowHeight = max(rowHeight, itemHeight) } } } diff --git a/Projects/DSKit/Sources/Components/PokitHeader.swift b/Projects/DSKit/Sources/Components/PokitHeader.swift index 3523a0a6..804ff627 100644 --- a/Projects/DSKit/Sources/Components/PokitHeader.swift +++ b/Projects/DSKit/Sources/Components/PokitHeader.swift @@ -7,6 +7,8 @@ import SwiftUI +import Util + public struct PokitHeader: View { private let title: String? @@ -28,12 +30,10 @@ public struct PokitHeader: View { .padding(.horizontal, 20) .padding(.vertical, 12) .background(.pokit(.bg(.base))) - .overlay { - if let title { - Text(title) - .pokitFont(.title3) - .foregroundStyle(.pokit(.text(.primary))) - } + .overlay(ifLet: title) { title in + Text(title) + .pokitFont(.title3) + .foregroundStyle(.pokit(.text(.primary))) } } } diff --git a/Projects/DSKit/Sources/Components/PokitLinkCard.swift b/Projects/DSKit/Sources/Components/PokitLinkCard.swift index 826385cd..a6bbf4b7 100644 --- a/Projects/DSKit/Sources/Components/PokitLinkCard.swift +++ b/Projects/DSKit/Sources/Components/PokitLinkCard.swift @@ -138,7 +138,7 @@ public struct PokitLinkCard: View { @ViewBuilder private func badges() -> some View { - let isUnCategorized = link.categoryName == "미분류" + let isUnCategorized = link.categoryName == Constants.미분류 HStack(spacing: 6) { PokitBadge( @@ -175,9 +175,6 @@ public struct PokitLinkCard: View { image .resizable() .aspectRatio(contentMode: .fill) - } else if phase.error != nil { - placeholder - .onAppear { fetchMetaData?() } } else { placeholder } @@ -186,6 +183,10 @@ public struct PokitLinkCard: View { .frame(width: 124, height: 94) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } + .onCompletion { result in + guard case .failure(_) = result else { return } + fetchMetaData?() + } } private var divider: some View { diff --git a/Projects/DSKit/Sources/Components/PokitLinkPopup.swift b/Projects/DSKit/Sources/Components/PokitLinkPopup.swift index b4d9de71..866acb2d 100644 --- a/Projects/DSKit/Sources/Components/PokitLinkPopup.swift +++ b/Projects/DSKit/Sources/Components/PokitLinkPopup.swift @@ -57,7 +57,7 @@ public struct PokitLinkPopup: View { VStack(alignment: .leading, spacing: 0) { Text(title) .lineLimit(2) - .pokitFont(.b2(.b)) + .pokitFont(titleFont) .multilineTextAlignment(.leading) .foregroundStyle(textColor) @@ -76,7 +76,7 @@ public struct PokitLinkPopup: View { .foregroundStyle(.pokit(.icon(.inverseWh))) } - Spacer(minLength: 72) + Spacer() } closeButton @@ -90,10 +90,17 @@ public struct PokitLinkPopup: View { private var closeButton: some View { Button(action: closedPopup) { - Image(.icon(.x)) - .resizable() - .frame(width: 24, height: 24) - .foregroundStyle(iconColor) + Group { + if case .report = type { + Image(.icon(.check)) + .resizable() + } else { + Image(.icon(.x)) + .resizable() + } + } + .frame(width: 24, height: 24) + .foregroundStyle(iconColor) } } @@ -109,7 +116,7 @@ public struct PokitLinkPopup: View { case .link, .text, .warning: UINotificationFeedbackGenerator() .notificationOccurred(.warning) - case .success: + case .success, .report: UINotificationFeedbackGenerator() .notificationOccurred(.success) case .error: @@ -125,7 +132,7 @@ public struct PokitLinkPopup: View { return .pokit(.bg(.tertiary)) case .success: return .pokit(.bg(.success)) - case .error: + case .error, .report: return .pokit(.bg(.error)) case .warning: return .pokit(.bg(.warning)) @@ -151,13 +158,21 @@ public struct PokitLinkPopup: View { } } + private var titleFont: PokitFont { + switch type { + case .link: return .b2(.b) + default: return .b3(.b) + } + } + private var title: String { switch type { case let .link(title, _), let .text(title), let .success(title), let .error(title), - let .warning(title): + let .warning(title), + let .report(title): return title default: return "" } @@ -171,6 +186,7 @@ public extension PokitLinkPopup { case success(title: String) case error(title: String) case warning(title: String) + case report(title: String) } } @@ -198,5 +214,9 @@ public extension PokitLinkPopup { PokitLinkPopup( type: .constant(.warning(title: "저장공간 부족")) ) + + PokitLinkPopup( + type: .constant(.report(title: "신고가 완료되었습니다")) + ) } } diff --git a/Projects/DSKit/Sources/Components/PokitList.swift b/Projects/DSKit/Sources/Components/PokitList.swift index ac0b3a76..9109957d 100644 --- a/Projects/DSKit/Sources/Components/PokitList.swift +++ b/Projects/DSKit/Sources/Components/PokitList.swift @@ -33,11 +33,7 @@ public struct PokitList: View { public var body: some View { if list.isEmpty { - VStack { - PokitCaution(type: .카테고리없음) - - Spacer() - } + PokitCaution(type: .카테고리없음) } else { ScrollView { VStack(spacing: 0) { @@ -59,7 +55,14 @@ public struct PokitList: View { action(item) } label: { HStack(spacing: 12) { - thumbNail(url: item.categoryImage.imageURL) + if item.categoryName == Constants.미분류 { + Image(.image(.unpokited)) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 60, height: 60) + } else { + thumbNail(url: item.categoryImage.imageURL) + } VStack(alignment: .leading, spacing: 4) { Text(item.categoryName) @@ -83,15 +86,13 @@ public struct PokitList: View { } .padding(.vertical, 12) .padding(.horizontal, 20) - .background { - if isSelected { - Color.pokit(.bg(.primary)) - .matchedGeometryEffect(id: "SELECT", in: heroEffect) - } else { - isDisabled - ? Color.pokit(.bg(.disable)) - : Color.pokit(.bg(.base)) - } + .background(if: isSelected) { + Color.pokit(.bg(.primary)) + .matchedGeometryEffect(id: "SELECT", in: heroEffect) + } else: { + isDisabled + ? Color.pokit(.bg(.disable)) + : Color.pokit(.bg(.base)) } } .animation(.pokitDissolve, value: isSelected) diff --git a/Projects/Feature/FeatureCategorySetting/Sources/Sheet/ProfileBottomSheet.swift b/Projects/DSKit/Sources/Components/PokitProfileBottomSheet.swift similarity index 68% rename from Projects/Feature/FeatureCategorySetting/Sources/Sheet/ProfileBottomSheet.swift rename to Projects/DSKit/Sources/Components/PokitProfileBottomSheet.swift index 6807985f..8a3df705 100644 --- a/Projects/Feature/FeatureCategorySetting/Sources/Sheet/ProfileBottomSheet.swift +++ b/Projects/DSKit/Sources/Components/PokitProfileBottomSheet.swift @@ -1,31 +1,30 @@ // -// ProfileBottomSheet.swift -// Feature +// PokitProfileBottomSheet.swift +// DSKit +// +// Created by 김민호 on 2/24/25. // -// Created by 김민호 on 7/25/24. import SwiftUI -import Domain -import CoreKit -import DSKit +import Util import NukeUI -public struct ProfileBottomSheet: View { +public struct PokitProfileBottomSheet: View { @State private var height: CGFloat = 0 - @State private var images: [BaseCategoryImage] - let selectedImage: BaseCategoryImage? + @State private var images: [ImageType] + let selectedImage: ImageType? private let colmumns = [ GridItem(.fixed(72), spacing: 20), GridItem(.fixed(72), spacing: 20), GridItem(.fixed(72), spacing: 0) ] - private let delegateSend: ((ProfileBottomSheet.Delegate) -> Void)? + private let delegateSend: ((PokitProfileBottomSheet.Delegate) -> Void)? public init( - selectedImage: BaseCategoryImage?, - images: [BaseCategoryImage], - delegateSend: ((ProfileBottomSheet.Delegate) -> Void)? + selectedImage: ImageType?, + images: [ImageType], + delegateSend: ((PokitProfileBottomSheet.Delegate) -> Void)? ) { self.selectedImage = selectedImage self.images = images @@ -34,7 +33,7 @@ public struct ProfileBottomSheet: View { } //MARK: - View -public extension ProfileBottomSheet { +public extension PokitProfileBottomSheet { @MainActor var body: some View { ScrollView { @@ -89,18 +88,35 @@ public extension ProfileBottomSheet { } } //MARK: - Delegate -public extension ProfileBottomSheet { +public extension PokitProfileBottomSheet { enum Delegate: Equatable { - case 이미지_선택했을때(BaseCategoryImage) + case 이미지_선택했을때(ImageType) } } //MARK: - Preview +#if DEBUG +struct BaseCategoryImageMock: Equatable, Identifiable, CategoryImage { + public var id: Int + public var imageURL: String +} +extension BaseCategoryImageMock { + static var mock: [Self] { + return [ + BaseCategoryImageMock( + id: 231, + imageURL: "https://pokit-storage.s3.ap-northeast-2.amazonaws.com/logo/pokit.png" + ) + ] + } +} #Preview { - ProfileBottomSheet( - selectedImage: BaseCategoryImage(imageId: 312, imageURL: "https://pokit-storage.s3.ap-northeast-2.amazonaws.com/logo/pokit.png"), - images: CategoryImageResponse.mock.map { $0.toDomain() }, + PokitProfileBottomSheet( + selectedImage: BaseCategoryImageMock( + id: 312, + imageURL: "https://pokit-storage.s3.ap-northeast-2.amazonaws.com/logo/pokit.png"), + images: BaseCategoryImageMock.mock, delegateSend: nil ) } - +#endif diff --git a/Projects/DSKit/Sources/Components/PokitSelect.swift b/Projects/DSKit/Sources/Components/PokitSelect.swift index 8da611e8..cfddb5c0 100644 --- a/Projects/DSKit/Sources/Components/PokitSelect.swift +++ b/Projects/DSKit/Sources/Components/PokitSelect.swift @@ -14,8 +14,8 @@ public struct PokitSelect: View { private var selectedItem: Item? @State private var state: PokitSelect.SelectState - @State - private var showSheet: Bool = false + @Binding + private var isPresented: Bool private let label: String private let list: [Item]? @@ -24,6 +24,7 @@ public struct PokitSelect: View { public init( selectedItem: Binding = .constant(nil), + isPresented: Binding, state: PokitSelect.SelectState = .default, label: String, list: [Item]?, @@ -31,6 +32,7 @@ public struct PokitSelect: View { addAction: (() -> Void)? ) { self._selectedItem = selectedItem + self._isPresented = isPresented if selectedItem.wrappedValue != nil { self.state = .input } else { @@ -49,7 +51,7 @@ public struct PokitSelect: View { partSelectButton } .onChange(of: selectedItem) { onChangedSeletedItem($0) } - .sheet(isPresented: $showSheet) { + .sheet(isPresented: $isPresented) { PokitSelectSheet( list: list, itemSelected: { item in @@ -104,14 +106,14 @@ public struct PokitSelect: View { } private func partSelectButtonTapped() { - showSheet = true + isPresented = true } private func listCellTapped(_ item: Item) { withAnimation(.pokitDissolve) { self.selectedItem = item } - showSheet = false + isPresented = false } private func onChangedSeletedItem(_ newValue: Item?) { @@ -119,7 +121,7 @@ public struct PokitSelect: View { } private func listDismiss() { - showSheet = false + isPresented = false } } diff --git a/Projects/DSKit/Sources/Components/PokitTextButton.swift b/Projects/DSKit/Sources/Components/PokitTextButton.swift index 286ff6f0..0a58ffe4 100644 --- a/Projects/DSKit/Sources/Components/PokitTextButton.swift +++ b/Projects/DSKit/Sources/Components/PokitTextButton.swift @@ -41,6 +41,7 @@ public struct PokitTextButton: View { .foregroundStyle(self.state.textColor) .padding(.horizontal, self.size.hPadding) .padding(.vertical, self.size.vPadding) + .frame(minWidth: self.size.minWidth) .background { RoundedRectangle(cornerRadius: shape.radius(size: self.size), style: .continuous) .fill(self.state.backgroundColor) @@ -49,6 +50,5 @@ public struct PokitTextButton: View { .stroke(self.state.backgroundStrokeColor, lineWidth: 1) } } - .frame(minWidth: self.size.minWidth) } } diff --git a/Projects/DSKit/Sources/Foundation/PokitButtonStyle.swift b/Projects/DSKit/Sources/Foundation/PokitButtonStyle.swift index 3c0e7225..a75d3a30 100644 --- a/Projects/DSKit/Sources/Foundation/PokitButtonStyle.swift +++ b/Projects/DSKit/Sources/Foundation/PokitButtonStyle.swift @@ -13,6 +13,7 @@ public enum PokitButtonStyle { case stroke(PokitButtonStyle.ButtonType) case filled(PokitButtonStyle.ButtonType) case disable + case opacity } public enum ButtonType: Equatable { @@ -43,6 +44,12 @@ extension PokitButtonStyle.State { } case .disable: return .pokit(.bg(.disable)) + case .opacity: + return Color( + red: 67 / 255, + green: 67 / 255, + blue: 67 / 255 + ).opacity(0.7) } } @@ -56,14 +63,15 @@ extension PokitButtonStyle.State { } case .disable: return .pokit(.border(.disable)) + case .opacity: return .clear } } var iconColor: Color { switch self { - case .default: return .pokit(.icon(.disable)) + case .default: return .pokit(.icon(.tertiary)) case .stroke(_): return .pokit(.icon(.primary)) - case .filled(_): return .pokit(.icon(.inverseWh)) + case .filled(_), .opacity: return .pokit(.icon(.inverseWh)) case .disable: return .pokit(.icon(.disable)) } } @@ -72,7 +80,7 @@ extension PokitButtonStyle.State { switch self { case .default: return .pokit(.text(.tertiary)) case .stroke(_): return .pokit(.text(.primary)) - case .filled(_): return .pokit(.text(.inverseWh)) + case .filled(_), .opacity: return .pokit(.text(.inverseWh)) case .disable: return .pokit(.text(.disable)) } } diff --git a/Projects/DSKit/Sources/Foundation/PokitImage.swift b/Projects/DSKit/Sources/Foundation/PokitImage.swift index 16354ee4..b89a404b 100644 --- a/Projects/DSKit/Sources/Foundation/PokitImage.swift +++ b/Projects/DSKit/Sources/Foundation/PokitImage.swift @@ -50,7 +50,7 @@ public enum PokitImage { case .trash: return DSKitAsset.iconTrash.swiftUIImage case .plusR: - return DSKitAsset.iconPlus.swiftUIImage + return DSKitAsset.iconPlusR.swiftUIImage case .remind: return DSKitAsset.iconRemind.swiftUIImage case .reminder: @@ -103,6 +103,8 @@ public enum PokitImage { return DSKitAsset.iconAlluncheck.swiftUIImage case .report: return DSKitAsset.iconReport.swiftUIImage + case .tack: + return DSKitAsset.iconTack.swiftUIImage } case .logo(let name): switch name { @@ -124,6 +126,10 @@ public enum PokitImage { return DSKitAsset.imageConfetti.swiftUIImage case .firecracker: return DSKitAsset.imageFirecracker.swiftUIImage + case .profile: + return DSKitAsset.imageProfile.swiftUIImage + case .unpokited: + return DSKitAsset.unpokited.swiftUIImage } } } @@ -174,6 +180,7 @@ public extension PokitImage { case allCheck case allUncheck case report + case tack } enum Logo { @@ -189,5 +196,7 @@ public extension PokitImage { enum PokitImage { case confetti case firecracker + case profile + case unpokited } } diff --git a/Projects/DSKit/Sources/Foundation/PokitInputStyle.swift b/Projects/DSKit/Sources/Foundation/PokitInputStyle.swift index 41b32aec..88c85914 100644 --- a/Projects/DSKit/Sources/Foundation/PokitInputStyle.swift +++ b/Projects/DSKit/Sources/Foundation/PokitInputStyle.swift @@ -28,7 +28,9 @@ public enum PokitInputStyle: Equatable { var backgroundColor: Color { switch self { - case .default, .input, .active, .error: + case .default, .input: + return .pokit(.bg(.primary)) + case .active, .error: return .pokit(.bg(.base)) case .disable: return .pokit(.bg(.disable)) @@ -43,13 +45,16 @@ public enum PokitInputStyle: Equatable { var backgroundStrokeColor: Color { switch self { - case .input, .memo: + case .default, .input: return .clear case .active: return .pokit(.border(.brand)) case .disable: return .pokit(.border(.disable)) - case .readOnly, .default: + //TODO: 메모적용 값 확인 필요 + case .memo: + return .clear + case .readOnly: return .pokit(.border(.secondary)) case .error: return .pokit(.border(.error)) diff --git a/Projects/DSKit/Sources/Modifiers/PokitFloatButtonModifier.swift b/Projects/DSKit/Sources/Modifiers/PokitFloatButtonModifier.swift new file mode 100644 index 00000000..e32faa54 --- /dev/null +++ b/Projects/DSKit/Sources/Modifiers/PokitFloatButtonModifier.swift @@ -0,0 +1,44 @@ +// +// PokitFloatButtonModifier.swift +// DSKit +// +// Created by 김민호 on 1/16/25. +// +import SwiftUI + +private struct PokitFloatButtonModifier: ViewModifier { + let action: () -> Void + + func body(content: Content) -> some View { + content + .overlay(alignment: .bottomTrailing) { + Button(action: action) { + Image(.icon(.plus)) + .resizable() + .frame(width: 36, height: 36) + .padding(12) + .foregroundStyle(.pokit(.icon(.inverseWh))) + .background { + RoundedRectangle(cornerRadius: 9999, style: .continuous) + .fill(.pokit(.bg(.brand))) + } + .frame(width: 60, height: 60) + } + .padding(.trailing, 20) + .padding(.bottom, 39) + } + } +} + +public extension View { + func pokitFloatButton(action: @escaping () -> Void) -> some View { + return self.modifier(PokitFloatButtonModifier(action: action)) + } +} + +#Preview { + ZStack { + Color.black.ignoresSafeArea() + .pokitFloatButton(action: {}) + } +} diff --git a/Projects/DSKit/Sources/Modifiers/ScrollOffsetKey.swift b/Projects/DSKit/Sources/Modifiers/ScrollOffsetKey.swift new file mode 100644 index 00000000..f6bf0341 --- /dev/null +++ b/Projects/DSKit/Sources/Modifiers/ScrollOffsetKey.swift @@ -0,0 +1,15 @@ +// +// ScrollOffsetKey.swift +// DSKit +// +// Created by 김민호 on 5/18/25. +// + +import SwiftUI + +public struct ScrollOffsetKey: PreferenceKey { + public static var defaultValue: CGFloat = 0 + public static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value += nextValue() + } +} diff --git a/Projects/Domain/Resources/.gitkeep b/Projects/Domain/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Domain/Resources/Resource.swift b/Projects/Domain/Resources/Resource.swift deleted file mode 100644 index 3780fc99..00000000 --- a/Projects/Domain/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Source.stencil.swift -// Manifests -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Domain/Sources/Base/BaseCategoryItem.swift b/Projects/Domain/Sources/Base/BaseCategoryItem.swift index e1f05970..dd97a483 100644 --- a/Projects/Domain/Sources/Base/BaseCategoryItem.swift +++ b/Projects/Domain/Sources/Base/BaseCategoryItem.swift @@ -9,13 +9,17 @@ import Foundation import Util -public struct BaseCategoryItem: Identifiable, Equatable, PokitSelectItem, PokitCardItem, Sortable { +public struct BaseCategoryItem: Identifiable, Equatable, PokitSelectItem, PokitCardItem, Sortable, Shareable { public let id: Int public let userId: Int public let categoryName: String public let categoryImage: BaseCategoryImage public var contentCount: Int public let createdAt: String + public let openType: BaseOpenType + public let keywordType: BaseInterestType + public let userCount: Int + public let isFavorite: Bool public init( id: Int, @@ -23,7 +27,11 @@ public struct BaseCategoryItem: Identifiable, Equatable, PokitSelectItem, PokitC categoryName: String, categoryImage: BaseCategoryImage, contentCount: Int, - createdAt: String + createdAt: String, + openType: BaseOpenType, + keywordType: BaseInterestType, + userCount: Int, + isFavorite: Bool ) { self.id = id self.userId = userId @@ -31,5 +39,9 @@ public struct BaseCategoryItem: Identifiable, Equatable, PokitSelectItem, PokitC self.categoryImage = categoryImage self.contentCount = contentCount self.createdAt = createdAt + self.openType = openType + self.keywordType = keywordType + self.userCount = userCount + self.isFavorite = isFavorite } } diff --git a/Projects/Domain/Sources/Base/BaseContentItem.swift b/Projects/Domain/Sources/Base/BaseContentItem.swift index d630964d..27ebd387 100644 --- a/Projects/Domain/Sources/Base/BaseContentItem.swift +++ b/Projects/Domain/Sources/Base/BaseContentItem.swift @@ -21,6 +21,7 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta public let createdAt: String public let isRead: Bool? public var isFavorite: Bool? + public let keyword: String? public init( id: Int, @@ -33,7 +34,8 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta domain: String, createdAt: String, isRead: Bool?, - isFavorite: Bool? + isFavorite: Bool?, + keyword: String? = nil ) { self.id = id self.categoryName = categoryName @@ -46,5 +48,6 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta self.createdAt = createdAt self.isRead = isRead self.isFavorite = isFavorite + self.keyword = keyword } } diff --git a/Projects/Domain/Sources/Base/BaseInterest.swift b/Projects/Domain/Sources/Base/BaseInterest.swift new file mode 100644 index 00000000..9d2c7d94 --- /dev/null +++ b/Projects/Domain/Sources/Base/BaseInterest.swift @@ -0,0 +1,22 @@ +// +// BaseInterest.swift +// Domain +// +// Created by 김도형 on 1/29/25. +// + +import Foundation + +public struct BaseInterest: Equatable, Identifiable, Hashable { + public let id = UUID() + public let code: String + public let description: String + + public func hash(into hasher: inout Hasher) { + hasher.combine(code) + } + + public static func ==(lhs: BaseInterest, rhs: BaseInterest) -> Bool { + lhs.code == rhs.code + } +} diff --git a/Projects/Domain/Sources/Base/BaseProfile.swift b/Projects/Domain/Sources/Base/BaseProfile.swift new file mode 100644 index 00000000..283a0e35 --- /dev/null +++ b/Projects/Domain/Sources/Base/BaseProfile.swift @@ -0,0 +1,21 @@ +// +// BaseProfile.swift +// Domain +// +// Created by 김민호 on 2/24/25. +// + +import Util + +public struct BaseProfile: Equatable, Identifiable, CategoryImage { + public let id: Int + public let imageURL: String + + public init( + id: Int, + imageURL: String + ) { + self.id = id + self.imageURL = imageURL + } +} diff --git a/Projects/Domain/Sources/Base/BaseUser.swift b/Projects/Domain/Sources/Base/BaseUser.swift index 526c15f4..d770199a 100644 --- a/Projects/Domain/Sources/Base/BaseUser.swift +++ b/Projects/Domain/Sources/Base/BaseUser.swift @@ -11,4 +11,5 @@ public struct BaseUser: Equatable { public let id: Int public let email: String public let nickname: String + public let profile: BaseProfile? } diff --git a/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift b/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift index e67218ed..00c3622e 100644 --- a/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift +++ b/Projects/Domain/Sources/DTO/Base/BaseContentResponse+Extension.swift @@ -23,7 +23,8 @@ public extension ContentBaseResponse { domain: self.domain, createdAt: self.createdAt, isRead: self.isRead, - isFavorite: self.isFavorite + isFavorite: self.isFavorite, + keyword: self.keyword ) } } diff --git a/Projects/Domain/Sources/DTO/Base/BaseInterest+Extension.swift b/Projects/Domain/Sources/DTO/Base/BaseInterest+Extension.swift new file mode 100644 index 00000000..20b4f677 --- /dev/null +++ b/Projects/Domain/Sources/DTO/Base/BaseInterest+Extension.swift @@ -0,0 +1,19 @@ +// +// BaseInterest+Extension.swift +// Domain +// +// Created by 김도형 on 1/29/25. +// + +import Foundation + +import CoreKit + +public extension InterestResponse { + func toDomian() -> BaseInterest { + return BaseInterest( + code: self.code, + description: self.description + ) + } +} diff --git a/Projects/Domain/Sources/DTO/Category/CategoryListInquiryResponse+Extention.swift b/Projects/Domain/Sources/DTO/Category/CategoryListInquiryResponse+Extention.swift index 8dfa9c65..1c776617 100644 --- a/Projects/Domain/Sources/DTO/Category/CategoryListInquiryResponse+Extention.swift +++ b/Projects/Domain/Sources/DTO/Category/CategoryListInquiryResponse+Extention.swift @@ -8,6 +8,7 @@ import Foundation import CoreKit +import Util public extension CategoryListInquiryResponse { func toDomain() -> BaseCategoryListInquiry { @@ -29,7 +30,11 @@ public extension CategoryItemInquiryResponse { categoryName: self.categoryName, categoryImage: self.categoryImage.toDomain(), contentCount: self.contentCount, - createdAt: self.createdAt + createdAt: self.createdAt, + openType: BaseOpenType(rawValue: self.openType) ?? .비공개, + keywordType: BaseInterestType(rawValue: self.keywordType.slashConvertUnderBar) ?? .default, + userCount: self.userCount, + isFavorite: self.isFavorite ) } } diff --git a/Projects/Domain/Sources/DTO/User/BaseProfileImageResponse+Extensions.swift b/Projects/Domain/Sources/DTO/User/BaseProfileImageResponse+Extensions.swift new file mode 100644 index 00000000..ecd7ca7b --- /dev/null +++ b/Projects/Domain/Sources/DTO/User/BaseProfileImageResponse+Extensions.swift @@ -0,0 +1,16 @@ +// +// BaseProfileImageResponse+Extensions.swift +// Domain +// +// Created by 김민호 on 2/25/25. +// + +import Foundation + +import CoreKit + +public extension BaseProfileImageResponse { + func toDomain() -> BaseProfile { + return .init(id: self.id, imageURL: self.url) + } +} diff --git a/Projects/Domain/Sources/DTO/User/BaseUserResponse+Extension.swift b/Projects/Domain/Sources/DTO/User/BaseUserResponse+Extension.swift index 2854eaf7..d24d7418 100644 --- a/Projects/Domain/Sources/DTO/User/BaseUserResponse+Extension.swift +++ b/Projects/Domain/Sources/DTO/User/BaseUserResponse+Extension.swift @@ -14,7 +14,10 @@ public extension BaseUserResponse { return .init( id: self.id, email: self.email, - nickname: self.nickname + nickname: self.nickname, + profile: self.profileImage == nil + ? nil + : BaseProfile(id: self.profileImage?.id ?? 0, imageURL: self.profileImage?.url ?? "") ) } } diff --git a/Projects/Domain/Sources/NicknameSetting/NicknameSetting.swift b/Projects/Domain/Sources/NicknameSetting/NicknameSetting.swift index 1cd932ad..36d7f47d 100644 --- a/Projects/Domain/Sources/NicknameSetting/NicknameSetting.swift +++ b/Projects/Domain/Sources/NicknameSetting/NicknameSetting.swift @@ -13,6 +13,10 @@ public struct NicknameSetting: Equatable { public var user: BaseUser? = nil /// 닉네임 중복 여부 public var isDuplicate: Bool + /// 유저 선택 프로필 + public var selectedProfile: BaseProfile? + /// 프로필에 설정할 수 있는 이미지 + public var imageList: [BaseProfile] // - MARK: Request /// 등록할 닉네임 @@ -20,9 +24,12 @@ public struct NicknameSetting: Equatable { public init( isDuplicate: Bool = false, - nickname: String = "" + nickname: String = "", + selectedProfile: BaseProfile? ) { + self.imageList = [] self.isDuplicate = isDuplicate self.nickname = nickname + self.selectedProfile = selectedProfile } } diff --git a/Projects/Domain/Sources/PokitCategorySetting/PokitCategorySetting.swift b/Projects/Domain/Sources/PokitCategorySetting/PokitCategorySetting.swift index 9d3eae7a..5d24f29f 100644 --- a/Projects/Domain/Sources/PokitCategorySetting/PokitCategorySetting.swift +++ b/Projects/Domain/Sources/PokitCategorySetting/PokitCategorySetting.swift @@ -7,14 +7,12 @@ import Foundation +import Util + public struct PokitCategorySetting: Equatable { // - MARK: Response - /// 카테고리(포킷) 리스트 - public var categoryListInQuiry: BaseCategoryListInquiry /// 카테고리(포킷)에 설정할 수 있는 이미지 public var imageList: [BaseCategoryImage] - /// 유저가 등록한 카테고리(포킷) 개수 - public var categoryTotalCount: Int // - MARK: Request /// 조회할 페이징 정보 public var pageable: BasePageable @@ -24,21 +22,19 @@ public struct PokitCategorySetting: Equatable { public var categoryName: String /// 등록할 카테고리(포킷) 이미지 public var categoryImage: BaseCategoryImage? + /// 카테고리 공개 여부(기본값: 공개) + public var openType: BaseOpenType + /// 유저가 선택한 카테고리 키워드(기본값: default - 미선택) + public var keywordType: BaseInterestType public init( categoryId: Int?, categoryName: String?, - categoryImage: BaseCategoryImage? + categoryImage: BaseCategoryImage?, + openType: BaseOpenType?, + keywordType: BaseInterestType? ) { - self.categoryListInQuiry = .init( - data: [], - page: 0, - size: 10, - sort: [], - hasNext: false - ) self.imageList = [] - self.categoryTotalCount = 0 self.pageable = .init( page: 0, size: 10, @@ -47,5 +43,7 @@ public struct PokitCategorySetting: Equatable { self.categoryId = categoryId self.categoryName = categoryName ?? "" self.categoryImage = categoryImage + self.openType = openType ?? .공개 + self.keywordType = keywordType ?? .default } } diff --git a/Projects/Domain/Sources/Recommend/Recommend.swift b/Projects/Domain/Sources/Recommend/Recommend.swift new file mode 100644 index 00000000..61fa2568 --- /dev/null +++ b/Projects/Domain/Sources/Recommend/Recommend.swift @@ -0,0 +1,41 @@ +// +// Recommend.swift +// Domain +// +// Created by 김도형 on 1/29/25. +// + +import Foundation + +public struct Recommend: Equatable { + // - MARK: Response + /// 콘텐츠 목록 + public var contentList: BaseContentListInquiry + public var pageable: BasePageable + public var myInterests: [BaseInterest] + public var interests: [BaseInterest] + /// 카테고리(포킷) 리스트 + public var categoryListInQuiry: BaseCategoryListInquiry + + public init() { + self.contentList = .init( + page: 0, + size: 0, + sort: [], + hasNext: false + ) + self.pageable = .init( + page: 0, size: 10, + sort: ["createdAt,desc"] + ) + self.myInterests = [] + self.interests = [] + self.categoryListInQuiry = BaseCategoryListInquiry( + data: [], + page: 0, + size: 0, + sort: [], + hasNext: false + ) + } +} diff --git a/Projects/Feature/FeatureCategoryDetail/Resources/.gitkeep b/Projects/Feature/FeatureCategoryDetail/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift index c9ce9e5d..0425f105 100644 --- a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift +++ b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift @@ -40,6 +40,9 @@ public struct CategoryDetailFeature { var isFavoriteFiltered: Bool { get { domain.condition.isFavoriteFlitered } } + var isFavoriteCategory: Bool { + get { domain.category.isFavorite } + } var sortType: SortType = .최신순 var categories: IdentifiedArrayOf? { @@ -57,12 +60,14 @@ public struct CategoryDetailFeature { var isCategorySheetPresented: Bool = false var isCategorySelectSheetPresented: Bool = false var isPokitDeleteSheetPresented: Bool = false - var isFilterSheetPresented: Bool = false /// pagenation var hasNext: Bool { domain.contentList.hasNext } var isLoading: Bool = true + var isContentsNotEmpty: Bool { + (isFavoriteCategory && contents.contains { $0.content.isFavorite == true }) || (!isFavoriteCategory && !contents.isEmpty) + } public init(category: BaseCategoryItem) { self.domain = .init(categpry: category) @@ -83,11 +88,16 @@ public struct CategoryDetailFeature { case binding(BindingAction) case dismiss case pagenation + + /// 즐겨찾기 or 안읽음 버튼 눌렀을 때 + case 분류_버튼_눌렀을때(SortCollectType) + case 정렬_버튼_눌렀을때 + case 공유_버튼_눌렀을때 case 카테고리_케밥_버튼_눌렀을때 case 카테고리_선택_버튼_눌렀을때 case 카테고리_선택했을때(BaseCategoryItem) - case 필터_버튼_눌렀을때 case 뷰가_나타났을때 + case 링크_추가_버튼_눌렀을때 } public enum InnerAction: Equatable { @@ -111,7 +121,6 @@ public struct CategoryDetailFeature { public enum ScopeAction { case categoryBottomSheet(PokitBottomSheet.Delegate) case categoryDeleteBottomSheet(PokitDeleteBottomSheet.Delegate) - case filterBottomSheet(CategoryFilterSheet.Delegate) case contents(IdentifiedActionOf) } @@ -119,6 +128,7 @@ public struct CategoryDetailFeature { case contentItemTapped(BaseContentItem) case linkCopyDetected(URL?) case 링크수정(contentId: Int) + case 링크추가(categoryId: Int) case 포킷삭제 case 포킷수정(BaseCategoryItem) case 포킷공유 @@ -174,6 +184,47 @@ private extension CategoryDetailFeature { case .binding: return .none + case .정렬_버튼_눌렀을때: + state.sortType = state.sortType == .최신순 + ? .오래된순 + : .최신순 + + state.domain.pageable.sort = [ + state.sortType == .최신순 ? "createdAt,desc" : "createdAt,asc" + ] + + return .concatenate( + .send(.inner(.pagenation_초기화), animation: .pokitDissolve), + .send(.async(.카테고리_내_컨텐츠_목록_조회_API)) + ) + + case let .분류_버튼_눌렀을때(type): + if type == .즐겨찾기 { + state.domain.condition.isFavoriteFlitered.toggle() + state.domain.condition.isUnreadFlitered = !state.domain.condition.isFavoriteFlitered + } else { + state.domain.condition.isUnreadFlitered.toggle() + state.domain.condition.isFavoriteFlitered = !state.domain.condition.isUnreadFlitered + } + return .concatenate( + .send(.inner(.pagenation_초기화), animation: .pokitDissolve), + .send(.async(.카테고리_내_컨텐츠_목록_조회_API)) + ) + + case .공유_버튼_눌렀을때: + kakaoShareClient.카테고리_카카오톡_공유( + CategoryKaKaoShareModel( + categoryName: state.domain.category.categoryName, + categoryId: state.domain.category.id, + imageURL: state.domain.category.categoryImage.imageURL + ) + ) + return .none + + case .링크_추가_버튼_눌렀을때: + let id = state.category.id + return .send(.delegate(.링크추가(categoryId: id))) + case .카테고리_케밥_버튼_눌렀을때: return .run { send in await send(.inner(.카테고리_시트_활성화(true))) } @@ -188,10 +239,6 @@ private extension CategoryDetailFeature { await send(.inner(.카테고리_선택_시트_활성화(false))) } - case .필터_버튼_눌렀을때: - state.isFilterSheetPresented.toggle() - return .none - case .dismiss: return .run { _ in await dismiss() } @@ -267,7 +314,7 @@ private extension CategoryDetailFeature { case .카테고리_목록_조회_API: return .run { send in let request = BasePageableRequest(page: 0, size: 30, sort: ["createdAt,desc"]) - let response = try await categoryClient.카테고리_목록_조회(request, true).toDomain() + let response = try await categoryClient.카테고리_목록_조회(request, true, true).toDomain() await send(.inner(.카테고리_목록_조회_API_반영(response))) } @@ -347,16 +394,6 @@ private extension CategoryDetailFeature { /// - 카테고리에 대한 `공유` / `수정` / `삭제` Delegate case .categoryBottomSheet(let delegateAction): switch delegateAction { - case .shareCellButtonTapped: - kakaoShareClient.카테고리_카카오톡_공유( - CategoryKaKaoShareModel( - categoryName: state.domain.category.categoryName, - categoryId: state.domain.category.id, - imageURL: state.domain.category.categoryImage.imageURL - ) - ) - state.isCategorySheetPresented = false - return .none case .editCellButtonTapped: return .run { [category = state.category] send in await send(.inner(.카테고리_시트_활성화(false))) @@ -384,25 +421,6 @@ private extension CategoryDetailFeature { try await categoryClient.카테고리_삭제(categoryId) } } - /// - 필터 버튼을 눌렀을 때 - case .filterBottomSheet(let delegateAction): - switch delegateAction { - case .dismiss: - state.isFilterSheetPresented.toggle() - return .none - case let .확인_버튼_눌렀을때(type, bookMarkSelected, unReadSelected): - state.isFilterSheetPresented.toggle() - state.domain.pageable.sort = [ - type == .최신순 ? "createdAt,desc" : "createdAt,asc" - ] - state.sortType = type - state.domain.condition.isFavoriteFlitered = bookMarkSelected - state.domain.condition.isUnreadFlitered = unReadSelected - return .concatenate( - .send(.inner(.pagenation_초기화), animation: .pokitDissolve), - .send(.async(.카테고리_내_컨텐츠_목록_조회_API)) - ) - } case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content)))): return .send(.delegate(.contentItemTapped(content))) diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift index 5c20db9f..5d0d80f6 100644 --- a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift +++ b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift @@ -11,13 +11,16 @@ import FeatureContentCard import Domain import DSKit import Util +import NukeUI @ViewAction(for: CategoryDetailFeature.self) public struct CategoryDetailView: View { /// - Properties @Perception.Bindable public var store: StoreOf - + @State private var currentOffset: CGFloat = 0 + @State private var targetOffset: CGFloat = 0 + @State private var isSticky: Bool = false /// - Initializer public init(store: StoreOf) { self.store = store @@ -27,18 +30,63 @@ public struct CategoryDetailView: View { public extension CategoryDetailView { var body: some View { WithPerceptionTracking { - VStack(spacing: 16) { - header - contentScrollView + ScrollView(showsIndicators: false) { + VStack(spacing: 24) { + header + scrollObservableView + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + contentScrollView + .padding(.horizontal, 20) + } header: { + VStack(spacing: 24) { + PokitDivider() + filterHeader + .padding(.horizontal, 20) + } + .padding(.bottom, 16) + .background(.white) + } + } + } + } + .onPreferenceChange(ScrollOffsetKey.self) { + if $0 != targetOffset { + currentOffset = $0 + } } - .padding(.horizontal, 20) + .onChange(of: currentOffset, perform: { newOffSet in + if newOffSet != targetOffset && newOffSet + targetOffset < 10 { + isSticky = true + } else { + isSticky = false + } + }) .padding(.top, 12) .pokitNavigationBar { navigationBar } + .overlay( + if: store.isContentsNotEmpty, + alignment: .bottomTrailing + ) { + Button(action: { send(.링크_추가_버튼_눌렀을때) }) { + Image(.icon(.plus)) + .resizable() + .frame(width: 36, height: 36) + .padding(12) + .foregroundStyle(.pokit(.icon(.inverseWh))) + .background { + RoundedRectangle(cornerRadius: 9999, style: .continuous) + .fill(.pokit(.bg(.brand))) + } + .frame(width: 60, height: 60) + } + .padding(.trailing, 20) + .padding(.bottom, 39) + } .ignoresSafeArea(edges: .bottom) .sheet(isPresented: $store.isCategorySheetPresented) { PokitBottomSheet( - items: [.share, .edit, .delete], - height: 224, + items: [.edit, .delete], delegateSend: { store.send(.scope(.categoryBottomSheet($0))) } ) } @@ -61,14 +109,6 @@ public extension CategoryDetailView { delegateSend: { store.send(.scope(.categoryDeleteBottomSheet($0))) } ) } - .sheet(isPresented: $store.isFilterSheetPresented) { - CategoryFilterSheet( - sortType: $store.sortType, - isBookMarkSelected: store.isFavoriteFiltered, - isUnreadSeleected: store.isUnreadFiltered, - delegateSend: { store.send(.scope(.filterBottomSheet($0))) } - ) - } .task { await send(.뷰가_나타났을때).finish() } } } @@ -76,25 +116,42 @@ public extension CategoryDetailView { //MARK: - Configure View private extension CategoryDetailView { var navigationBar: some View { - PokitHeader { + PokitHeader(title: isSticky ? store.category.categoryName : "") { PokitHeaderItems(placement: .leading) { PokitToolbarButton( .icon(.arrowLeft), action: { send(.dismiss) } ) } - PokitHeaderItems(placement: .trailing) { - PokitToolbarButton( - .icon(.kebab), - action: { send(.카테고리_케밥_버튼_눌렀을때) } - ) + if !store.isFavoriteCategory { + PokitHeaderItems(placement: .trailing) { + PokitToolbarButton( + .icon(.kebab), + action: { send(.카테고리_케밥_버튼_눌렀을때) } + ) + } } } .padding(.top, 8) } + @MainActor var header: some View { - VStack(spacing: 4) { + VStack(spacing: 0) { + LazyImage(url: URL(string: store.category.categoryImage.imageURL)) { state in + Group { + if let image = state.image { + image + .resizable() + } else { + PokitSpinner() + .foregroundStyle(.pokit(.icon(.brand))) + } + } + .frame(width: 100, height: 100) + .animation(.pokitDissolve, value: state.image) + } + .padding(.bottom, 2) HStack(spacing: 8) { /// cateogry title Button(action: { send(.카테고리_선택_버튼_눌렀을때) }) { @@ -105,63 +162,173 @@ private extension CategoryDetailView { .resizable() .frame(width: 24, height: 24) .foregroundStyle(.pokit(.icon(.primary))) - Spacer() } .buttonStyle(.plain) } - HStack { - Text("링크 \(store.category.contentCount)개") - .foregroundStyle(.pokit(.text(.secondary))) - .pokitFont(.detail1) - Spacer() + .padding(.bottom, 8) + if !store.isFavoriteCategory { + HStack(spacing: 3.5) { + let iconColor: Color = .pokit(.icon(.secondary)) + let textColor: Color = .pokit(.text(.tertiary)) + + if store.category.openType == .비공개 { + HStack(spacing: 2) { + Image(.icon(.lock)) + .resizable() + .frame(width: 16, height: 16) + .foregroundStyle(iconColor) + Text("비밀") + .foregroundStyle(textColor) + .pokitFont(.b2(.m)) + } + } + HStack(spacing: 2) { + Image(.icon(.link)) + .resizable() + .frame(width: 16, height: 16) + .foregroundStyle(iconColor) + Text("\(store.contents.count)개") + .foregroundStyle(textColor) + .pokitFont(.b2(.m)) + } + if store.category.keywordType != .default { + Text("#\(store.category.keywordType.title)") + .foregroundStyle(textColor) + .pokitFont(.b2(.m)) + .padding(.leading, 4.5) + } + } + .padding(.bottom, 16) PokitIconLButton( - "필터", - .icon(.filter), + "공유", + .icon(.share), state: .filled(.primary), - size: .small, + size: .medium, shape: .round, - action: { send(.필터_버튼_눌렀을때) } + action: { send(.공유_버튼_눌렀을때) } + ) + } + } + } + + @ViewBuilder + var filterHeader: some View { + let isFavoriteCategory = store.isFavoriteCategory + let favoriteContentsCount = store.contents.filter { $0.content.isFavorite ?? false }.count + if store.isContentsNotEmpty { + HStack(spacing: isFavoriteCategory ? 2 : 8) { + if isFavoriteCategory { + Image(.icon(.link)) + .resizable() + .frame(width: 16, height: 16) + .foregroundStyle(.pokit(.icon(.secondary))) + Text("\(favoriteContentsCount)개") + .foregroundStyle(.pokit(.text(.tertiary))) + .pokitFont(.b2(.m)) + } else { + favoriteButton + + unreadButton + } + + Spacer() + PokitIconLTextLink( + store.sortType.title, + icon: .icon(.align), + action: { send(.정렬_버튼_눌렀을때) } ) + .contentTransition(.numericText()) } } } + @ViewBuilder + var favoriteButton: some View { + if store.isFavoriteFiltered { + PokitIconRButton( + "즐겨찾기", + .icon(.x), + state: .filled(.primary), + size: .small, + shape: .round, + action: { send(.분류_버튼_눌렀을때(.즐겨찾기)) } + ) + } else { + PokitTextButton( + "즐겨찾기", + state: .default(.secondary), + size: .small, + shape: .round, + action: { send(.분류_버튼_눌렀을때(.즐겨찾기)) } + ) + } + } + + @ViewBuilder + var unreadButton: some View { + if store.isUnreadFiltered { + PokitIconRButton( + "안읽음", + .icon(.x), + state: .filled(.primary), + size: .small, + shape: .round, + action: { send(.분류_버튼_눌렀을때(.안읽음)) } + ) + } else { + PokitTextButton( + "안읽음", + state: .default(.secondary), + size: .small, + shape: .round, + action: { send(.분류_버튼_눌렀을때(.안읽음)) } + ) + } + } + var contentScrollView: some View { Group { if !store.isLoading { if store.contents.isEmpty { - VStack { - PokitCaution(type: .링크없음) - .padding(.top, 20) - - Spacer() - } + PokitCaution( + type: .포킷상세_링크없음, + action: { send(.링크_추가_버튼_눌렀을때) } + ) } else { - ScrollView(showsIndicators: false) { - LazyVStack(spacing: 0) { - ForEach( - Array(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 - + LazyVStack(spacing: 0) { + ForEach( + Array(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 + + if !self.store.isFavoriteCategory { ContentCardView( store: store, type: .linkList, isFirst: isFirst, isLast: isLast ) + } else { + if store.content.isFavorite == true { + ContentCardView( + store: store, + type: .linkList, + isFirst: isFirst, + isLast: isLast + ) + } } - - if store.hasNext { - PokitLoading() - .task { await send(.pagenation).finish() } - } - - Spacer() } - .padding(.bottom, 36) + + if store.hasNext { + PokitLoading() + .task { await send(.pagenation).finish() } + } + + Spacer() } + .padding(.bottom, 36) } } else { PokitLoading() @@ -193,6 +360,18 @@ private extension CategoryDetailView { ) } } + private var scrollObservableView: some View { + GeometryReader { proxy in + let offsetY = proxy.frame(in: .global).origin.y + Color.clear + .preference( + key: ScrollOffsetKey.self, + value: offsetY + ) + .onAppear { targetOffset = offsetY } + } + .frame(height: 0) + } } //MARK: - Preview #Preview { @@ -204,9 +383,14 @@ private extension CategoryDetailView { id: 0, userId: 0, categoryName: "포킷", - categoryImage: .init(imageId: 0, imageURL: ""), - contentCount: 16, - createdAt: "" + categoryImage: .init(imageId: 0, imageURL: Constants.mockImageUrl), + contentCount: 16, + createdAt: "", + //TODO: v2 property 수정 + openType: .비공개, + keywordType: .IT, + userCount: 0, + isFavorite: false ) ), reducer: { CategoryDetailFeature() } @@ -214,5 +398,3 @@ private extension CategoryDetailView { ) } } - - diff --git a/Projects/Feature/FeatureCategorySetting/Resources/.gitkeep b/Projects/Feature/FeatureCategorySetting/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureCategorySetting/Resources/Resource.swift b/Projects/Feature/FeatureCategorySetting/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureCategorySetting/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeatureCategorySetting/Sources/KeywordSelectType.swift b/Projects/Feature/FeatureCategorySetting/Sources/KeywordSelectType.swift new file mode 100644 index 00000000..8367f616 --- /dev/null +++ b/Projects/Feature/FeatureCategorySetting/Sources/KeywordSelectType.swift @@ -0,0 +1,37 @@ +// +// KeywordSelectType.swift +// Feature +// +// Created by 김민호 on 1/23/25. +// +import SwiftUI + +import DSKit + +public enum KeywordSelectType: Equatable { + case normal + case select(keywordName: String) + case warnning + + var fontColor: Color { + switch self { + case .normal, .select: + return .pokit(.text(.tertiary)) + case .warnning: + return .pokit(.text(.error)) + } + } + + var label: String { + switch self { + case .normal: + return "추천을 위해 포킷 키워드를 선택해 주세요." + + case let .select(keywordName): + return "#\(keywordName)" + + case .warnning: + return "포킷 키워드를 선택해 주세요." + } + } +} diff --git a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift index 4113db9f..6206d9f5 100644 --- a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift +++ b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingFeature.swift @@ -21,6 +21,10 @@ public struct PokitCategorySettingFeature { var pasteboard @Dependency(CategoryClient.self) var categoryClient + @Dependency(UserClient.self) + var userClient + @Dependency(KeyboardClient.self) + var keyboardClient /// - State @ObservableState public struct State: Equatable { @@ -37,20 +41,28 @@ public struct PokitCategorySettingFeature { var profileImages: [BaseCategoryImage] { get { domain.imageList } } - var itemList: IdentifiedArrayOf? { - guard let categoryList = domain.categoryListInQuiry.data else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - categoryList.forEach { category in - identifiedArray.append(category) - } - return identifiedArray + + var selectedKeywordType: BaseInterestType { + get { domain.keywordType } + set { domain.keywordType = newValue } + } + + var isPublicType: Bool { + get { domain.openType == .공개 ? true : false } + set { domain.openType = newValue ? .공개 : .비공개 } + } + var saveButtonEnabled: Bool { + !categoryName.isEmpty + && selectedProfile != nil + && (domain.openType == .공개 ? keywordSelectType != .normal : true) } let type: SettingType + var keywordSelectType: KeywordSelectType = .normal var isProfileSheetPresented: Bool = false + var isKeywordSheetPresented: Bool = false var pokitNameTextInpuState: PokitInputStyle.State = .default + var isKeyboardVisible: Bool = false @Shared(.inMemory("SelectCategory")) var categoryId: Int? /// - 포킷 수정 API / 추가 API /// categoryName @@ -59,15 +71,15 @@ public struct PokitCategorySettingFeature { public init( type: SettingType, - categoryId: Int? = nil, - categoryImage: BaseCategoryImage? = nil, - categoryName: String? = "" + category: BaseCategoryItem? = nil ) { self.type = type self.domain = .init( - categoryId: categoryId, - categoryName: categoryName, - categoryImage: categoryImage + categoryId: category?.id, + categoryName: category?.categoryName, + categoryImage: category?.categoryImage, + openType: category?.openType, + keywordType: category?.keywordType ) } } @@ -88,23 +100,25 @@ public struct PokitCategorySettingFeature { case 저장_버튼_눌렀을때 case 뷰가_나타났을때 case 포킷명지우기_버튼_눌렀을때 + case 키워드_바텀시트_활성화(Bool) + case 키워드_선택_버튼_눌렀을때(BaseInterestType) } public enum InnerAction: Equatable { - case 카테고리_목록_조회_API_반영(BaseCategoryListInquiry) case 프로필_목록_조회_API_반영(images: [BaseCategoryImage]) case 포킷_오류_핸들링(BaseError) case 카테고리_인메모리_저장(BaseCategoryItem) + case 키보드_감지_반영(Bool) } public enum AsyncAction: Equatable { - case 카테고리_목록_조회_API case 프로필_목록_조회_API case 클립보드_감지 + case 키보드_감지 } - public enum ScopeAction: Equatable { - case profile(ProfileBottomSheet.Delegate) + public enum ScopeAction { + case profile(PokitProfileBottomSheet.Delegate) } public enum DelegateAction: Equatable { @@ -153,6 +167,12 @@ private extension PokitCategorySettingFeature { /// - View Effect func handleViewAction(_ action: Action.View, state: inout State) -> Effect { switch action { + case .binding(\.isPublicType): + if !state.isPublicType { + state.selectedKeywordType = .default + state.keywordSelectType = .normal + } + return .none case .binding: return .none @@ -164,61 +184,101 @@ private extension PokitCategorySettingFeature { return .none case .저장_버튼_눌렀을때: - return .run { [domain = state.domain, - type = state.type] send in - switch type { - case .추가: - guard let image = domain.categoryImage else { return } - let request = CategoryEditRequest(categoryName: domain.categoryName, categoryImageId: image.id) - let response = try await categoryClient.카테고리_생성(request) - let responseToCategoryDomain = BaseCategoryItem( - id: response.categoryId, - userId: 0, - categoryName: response.categoryName, - categoryImage: BaseCategoryImage( - imageId: response.categoryImage.imageId, - imageURL: response.categoryImage.imageUrl - ), - contentCount: 0, - createdAt: "" - ) - 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 } - try await categoryClient.공유받은_카테고리_저장( - CopiedCategoryRequest( - originCategoryId: categoryId, + /// 전체공개 설정을 했는데 키워드 설정을 안했을 때 + if state.domain.openType == .공개 + && state.domain.keywordType == .default { + state.keywordSelectType = .warnning + return .none + } else { + return .run { [domain = state.domain, + type = state.type] send in + switch type { + case .추가: + guard let image = domain.categoryImage else { return } + let request = CategoryEditRequest( + categoryName: domain.categoryName, + categoryImageId: image.id, + openType: domain.openType.title, + keywordType: domain.keywordType.title + ) + let response = try await categoryClient.카테고리_생성(request) + let responseToCategoryDomain = BaseCategoryItem( + id: response.categoryId, + userId: 0, + categoryName: response.categoryName, + categoryImage: BaseCategoryImage( + imageId: response.categoryImage.imageId, + imageURL: response.categoryImage.imageUrl + ), + contentCount: 0, + createdAt: "", + //TODO: v2 property 수정 + openType: domain.openType, + keywordType: domain.keywordType, + userCount: 0, + isFavorite: false + ) + 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 + categoryImageId: image.id, + openType: domain.openType.title, + keywordType: domain.keywordType.title ) - ) - await send(.delegate(.settingSuccess)) + 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 } + try await categoryClient.공유받은_카테고리_저장( + CopiedCategoryRequest( + originCategoryId: categoryId, + categoryName: domain.categoryName, + categoryImageId: image.id, + keyword: domain.keywordType.title, + openType: domain.openType.title + ) + ) + await send(.delegate(.settingSuccess)) + } + } catch: { error, send in + guard let errorResponse = error as? ErrorResponse else { return } + await send(.inner(.포킷_오류_핸들링(BaseError(response: errorResponse)))) } - } catch: { error, send in - guard let errorResponse = error as? ErrorResponse else { return } - await send(.inner(.포킷_오류_핸들링(BaseError(response: errorResponse)))) } case .뷰가_나타났을때: + let selectType = state.selectedKeywordType + if selectType != .default { + state.keywordSelectType = .select(keywordName: selectType.title) + } /// 단순 조회API들의 나열이라 merge사용 return .merge( - .send(.async(.카테고리_목록_조회_API)), .send(.async(.프로필_목록_조회_API)), - .send(.async(.클립보드_감지)) + .send(.async(.클립보드_감지)), + .send(.async(.키보드_감지)) ) case .포킷명지우기_버튼_눌렀을때: state.domain.categoryName = "" return .none + + case let .키워드_바텀시트_활성화(isActive): + state.isKeywordSheetPresented = isActive + return .none + + //TODO: 액션 마이그레이션 시 반영 + case let .키워드_선택_버튼_눌렀을때(type): + state.domain.keywordType = type + state.keywordSelectType = .select(keywordName: type.title) + return .run { send in + await send(.view(.키워드_바텀시트_활성화(false))) + } } } @@ -234,10 +294,6 @@ private extension PokitCategorySettingFeature { } return .none - case let .카테고리_목록_조회_API_반영(response): - state.domain.categoryListInQuiry = response - return .none - case let .포킷_오류_핸들링(baseError): state.pokitNameTextInpuState = .error(message: baseError.message) return .none @@ -245,19 +301,16 @@ private extension PokitCategorySettingFeature { case let .카테고리_인메모리_저장(response): state.categoryId = response.id return .none + + case let .키보드_감지_반영(isVisible): + state.isKeyboardVisible = isVisible + return .none } } /// - Async Effect func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { switch action { - case .카테고리_목록_조회_API: - return .run { send in - 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.카테고리_프로필_목록_조회() @@ -272,6 +325,13 @@ private extension PokitCategorySettingFeature { await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) } } + + case .키보드_감지: + return .run { send in + for await detect in await keyboardClient.isVisible() { + await send(.inner(.키보드_감지_반영(detect)), animation: .pokitSpring) + } + } } } diff --git a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingView.swift b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingView.swift index 739291c7..b75cc834 100644 --- a/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingView.swift +++ b/Projects/Feature/FeatureCategorySetting/Sources/PokitCategorySettingView.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import Domain import DSKit +import Util import NukeUI @ViewAction(for: PokitCategorySettingFeature.self) @@ -30,21 +31,36 @@ public extension PokitCategorySettingView { VStack(spacing: 0) { thumbnailSection pokitNameSection - myPokitSection - saveButton + PokitDivider() + .padding(.horizontal, -20) + .padding(.top, 28) + openTypeSettingSection + keywordSection + Spacer() } + .padding(.top, 16) .padding(.horizontal, 20) + .overlay(alignment: .bottom) { + saveButton + } .padding(.top, 16) - .pokitMaxWidth() +// .pokitMaxWidth() .pokitNavigationBar { navigationBar } - .ignoresSafeArea(edges: isFocused ? [] : .bottom) .sheet(isPresented: $store.isProfileSheetPresented) { - ProfileBottomSheet( + PokitProfileBottomSheet( selectedImage: store.selectedProfile, images: store.profileImages, delegateSend: { store.send(.scope(.profile($0))) } ) } + .sheet(isPresented: $store.isKeywordSheetPresented) { + PokitKeywordBottomSheet( + selectedKeywordType: store.selectedKeywordType, + action: { send(.키워드_선택_버튼_눌렀을때($0)) } + ) + } + .ignoresSafeArea(.container, edges: .bottom) + .dismissKeyboard(focused: $isFocused) .task { await send(.뷰가_나타났을때).finish() } } } @@ -69,21 +85,24 @@ private extension PokitCategorySettingView { transaction: .init(animation: .spring) ) { phase in if let image = phase.image { - image - .resizable() - .roundedCorner(12, corners: .allCorners) + Circle().foregroundStyle(.pokit(.bg(.primary))) + .overlay { + image + .resizable() + .padding(10) + .clipShape(.circle) + } } else { WithPerceptionTracking { ZStack { - Color.pokit(.bg(.disable)) - + Color.pokit(.bg(.primary)) if store.selectedProfile?.imageURL != nil { PokitSpinner() .foregroundStyle(.pokit(.icon(.brand))) .frame(width: 48, height: 48) } } - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(.circle) } } } @@ -112,7 +131,7 @@ private extension PokitCategorySettingView { } .offset(x: 10) - .padding(.bottom, 7) + .padding(.bottom, 3) } } /// 타이틀 + 텍스트필드를 포함한 포킷명 입력 섹션 @@ -137,43 +156,59 @@ private extension PokitCategorySettingView { ) } } - /// 내포킷 리스트( ScrollView) - var myPokitSection: some View { - VStack(spacing: 8) { - if let itemList = store.itemList { - if itemList.isEmpty { - Spacer() - } else { - HStack { - Text("내 포킷") - .pokitFont(.b2(.m)) - .foregroundStyle(.pokit(.text(.secondary))) + /// 공개 여부 설정 + var openTypeSettingSection: some View { + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: $store.isPublicType) { + Text("전체 공개로 설정하기") + .pokitFont(.b1(.b)) + .foregroundStyle(.pokit(.text(.primary))) + } + .tint(.pokit(.icon(.brand))) + Text("포킷에 저장된 링크가 다른 사용자에게 추천됩니다.") + .pokitFont(.detail1) + .foregroundStyle(.pokit(.text(.tertiary))) + } + .padding(.vertical, 12) + .padding(.leading, 8) + .padding(.top, 16) + } + + /// 포킷 키워드 + var keywordSection: some View { + Group { + if store.isPublicType { + VStack(alignment: .leading, spacing: 4) { + Button(action: { send(.키워드_바텀시트_활성화(true)) }) { + Text("포킷 키워드") + .pokitFont(.b1(.b)) + .foregroundStyle(.pokit(.text(.primary))) Spacer() + Image(.icon(.arrowRight)) } - - - ScrollView { - ForEach(itemList, id: \.id) { item in - PokitItem(item: item) - } - } - .scrollIndicators(.hidden) + .buttonStyle(.plain) + Text(store.keywordSelectType.label) + .pokitFont(.detail1) + .foregroundStyle(store.keywordSelectType.fontColor) } - } else { - PokitLoading() + .padding(.vertical, 12) + .padding(.leading, 8) + .padding(.top, 8) } } - .padding(.top, 28) + .animation(.smooth, value: store.isPublicType) } /// 저장하기 버튼 var saveButton: some View { PokitBottomButton( "저장하기", - state: !store.categoryName.isEmpty && store.selectedProfile != nil + state: store.saveButtonEnabled ? .filled(.primary) : .disable, action: { send(.저장_버튼_눌렀을때) } ) + .gradientBackground() + .keyboardAnchor(store.isKeyboardVisible) } /// 내포킷 Item struct PokitItem: View { diff --git a/Projects/Feature/FeatureCategorySetting/Sources/Sheet/PokitKeywordBottomSheet.swift b/Projects/Feature/FeatureCategorySetting/Sources/Sheet/PokitKeywordBottomSheet.swift new file mode 100644 index 00000000..4fbaed1d --- /dev/null +++ b/Projects/Feature/FeatureCategorySetting/Sources/Sheet/PokitKeywordBottomSheet.swift @@ -0,0 +1,105 @@ +// +// PokitKeywordBottomSheet.swift +// Feature +// +// Created by 김민호 on 1/23/25. +// + +import SwiftUI + +import DSKit +import Util + +public struct PokitKeywordBottomSheet: View { + @State + private var height: CGFloat = 0 + @State + private var selectedKeywordType: BaseInterestType + @State + private var keywordSheetBottomButtonState: PokitButtonStyle.State = .disable + private let action: (BaseInterestType) -> Void + + public init( + selectedKeywordType: BaseInterestType, + action: @escaping (BaseInterestType) -> Void + ) { + self.selectedKeywordType = selectedKeywordType + self.keywordSheetBottomButtonState = selectedKeywordType == .default + ? .disable + : .filled(.primary) + self.action = action + } + + public var body: some View { + GeometryReader { proxy in + let bottomSafeArea = proxy.safeAreaInsets.bottom + VStack(alignment: .leading, spacing: 0) { + Text("포킷의 키워드를 선택해 주세요") + .pokitFont(.title1) + .foregroundStyle(.pokit(.text(.primary))) + Text("키워드 기반으로 다른 사용자에게\n링크가 추천됩니다") + .pokitFont(.title3) + .foregroundStyle(.pokit(.text(.secondary))) + .padding(.top, 12) + .fixedSize(horizontal: false, vertical: true) + PokitFlowLayout(rowSpacing: 16, colSpacing: 12) { + ForEach(BaseInterestType.allCases, id: \.self) { field in + let isSelected = selectedKeywordType != .default + if field != .default { + PokitTextChip( + field.title, + state: isSelected && field == selectedKeywordType + ? .filled(.primary) + : .default(.primary), + size: .medium + ) { + selectedKeywordType = field + } + } + } + .animation(.pokitDissolve, value: selectedKeywordType) + } + .padding(.top, 36) + .padding(.bottom, 40) + + PokitBottomButton( + "키워드 선택", + state: keywordSheetBottomButtonState, + action: { action(selectedKeywordType) } + ) + } + .padding(.horizontal, 20) + .padding(.top, 48) + .padding(.bottom, 36 - bottomSafeArea) + .onChange(of: selectedKeywordType) { _ in + keywordSheetBottomButtonState = .filled(.primary) + } + .background(.pokit(.bg(.base))) + .pokitPresentationCornerRadius() + .pokitPresentationBackground() + .presentationDragIndicator(.visible) + .readHeight() + .onPreferenceChange(HeightPreferenceKey.self) { height in + if let height { + self.height = height + } + } + .presentationDetents([.height(height)]) + .ignoresSafeArea(edges: [.bottom, .top]) + } + } +} + +#Preview { + ZStack { + Color.black + .sheet(isPresented: .constant(true)) { + PokitKeywordBottomSheet( + selectedKeywordType: .default, + action: { _ in } + ) + } + } +} + + diff --git a/Projects/Feature/FeatureCategorySettingDemo/Sources/FeatureCategorySettingDemoApp.swift b/Projects/Feature/FeatureCategorySettingDemo/Sources/FeatureCategorySettingDemoApp.swift index 902de847..54729a4d 100644 --- a/Projects/Feature/FeatureCategorySettingDemo/Sources/FeatureCategorySettingDemoApp.swift +++ b/Projects/Feature/FeatureCategorySettingDemo/Sources/FeatureCategorySettingDemoApp.swift @@ -9,19 +9,25 @@ import SwiftUI import ComposableArchitecture import FeatureCategorySetting +import FeatureIntro import Util @main struct FeatureCategorySettingDemoApp: App { var body: some Scene { WindowGroup { - NavigationStack { - PokitCategorySettingView( - store: Store( - initialState: .init(type: .추가), - reducer: { PokitCategorySettingFeature() } + DemoView(store: .init( + initialState: DemoFeature.State(), + reducer: { DemoFeature() } + )) { + NavigationStack { + PokitCategorySettingView( + store: Store( + initialState: .init(type: .추가), + reducer: { PokitCategorySettingFeature() } + ) ) - ) + } } } } diff --git a/Projects/Feature/FeatureCategorySharing/Resources/.gitkeep b/Projects/Feature/FeatureCategorySharing/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureCategorySharing/Resources/Resource.swift b/Projects/Feature/FeatureCategorySharing/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureCategorySharing/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingView.swift b/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingView.swift index d1bb4240..7a6fbd80 100644 --- a/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingView.swift +++ b/Projects/Feature/FeatureCategorySharing/Sources/CategorySharing/CategorySharingView.swift @@ -90,12 +90,7 @@ private extension CategorySharingView { Group { if !store.isLoading { if store.contents.isEmpty { - VStack { - PokitCaution(type: .링크없음) - .padding(.top, 20) - - Spacer() - } + PokitCaution(type: .링크없음) } else { ScrollView(showsIndicators: false) { LazyVStack(spacing: 0) { diff --git a/Projects/Feature/FeatureContentCard/Resources/.gitkeep b/Projects/Feature/FeatureContentCard/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureContentCard/Resources/Resource.swift b/Projects/Feature/FeatureContentCard/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureContentCard/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// 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 index 5255b2b1..d5bfae8c 100644 --- a/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift +++ b/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift @@ -48,11 +48,13 @@ public struct ContentCardFeature { case 메타데이터_조회 } + @CasePathable public enum InnerAction: Equatable { case 메타데이터_조회_수행_반영(String) case 즐겨찾기_API_반영(Bool) } + @CasePathable public enum AsyncAction: Equatable { case 메타데이터_조회_수행 case 즐겨찾기_API @@ -113,7 +115,7 @@ private extension ContentCardFeature { case .컨텐츠_항목_케밥_버튼_눌렀을때: return .send(.delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content: state.content))) case .메타데이터_조회: - return .send(.async(.메타데이터_조회_수행)) + return shared(.async(.메타데이터_조회_수행), state: &state) case .즐겨찾기_버튼_눌렀을때: guard let isFavorite = state.content.isFavorite else { return .none @@ -121,8 +123,8 @@ private extension ContentCardFeature { UIImpactFeedbackGenerator(style: .light) .impactOccurred() return isFavorite - ? .send(.async(.즐겨찾기_취소_API)) - : .send(.async(.즐겨찾기_API)) + ? shared(.async(.즐겨찾기_취소_API), state: &state) + : shared(.async(.즐겨찾기_API), state: &state) } } @@ -131,7 +133,7 @@ private extension ContentCardFeature { switch action { case let .메타데이터_조회_수행_반영(imageURL): state.content.thumbNail = imageURL - return .send(.async(.썸네일_수정_API)) + return shared(.async(.썸네일_수정_API), state: &state) case .즐겨찾기_API_반영(let favorite): state.content.isFavorite = favorite return .none @@ -146,9 +148,8 @@ private extension ContentCardFeature { return .none } return .run { send in - let imageURL = try await swiftSoupClient.parseOGImageURL(url) - guard let imageURL else { return } - await send(.inner(.메타데이터_조회_수행_반영(imageURL))) + let imageURL = try? await swiftSoupClient.parseOGImageURL(url) + await send(.inner(.메타데이터_조회_수행_반영(imageURL ?? Constants.기본_썸네일_주소.absoluteString))) } case .즐겨찾기_API: return .run { [id = state.content.id] send in @@ -164,10 +165,7 @@ private extension ContentCardFeature { return .run { [content = state.content] _ in let request = ThumbnailRequest(thumbnail: content.thumbNail) - try await contentClient.썸네일_수정( - contentId: "\(content.id)", - model: request - ) + try await contentClient.썸네일_수정("\(content.id)", request) } } } @@ -181,4 +179,19 @@ private extension ContentCardFeature { func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { return .none } + + func shared(_ action: Action, state: inout State) -> Effect { + switch action { + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + } + } } diff --git a/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift index 6e30407e..d5ea6705 100644 --- a/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift +++ b/Projects/Feature/FeatureContentCardTests/Sources/FeatureContentCardTests.swift @@ -1,10 +1,87 @@ import ComposableArchitecture import XCTest +import Domain +import CoreKit @testable import FeatureContentCard -final class FeatureContentCardTests: XCTestCase { - func test() { +final class SendTests: XCTestCase { + func test_primeTest() async { + let count = 10 + var sharedAverage: CFAbsoluteTime = 0.0 + for _ in 0.. String? = { _ in + "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + } + + $0[SwiftSoupClient.self].parseOGImageURL = parseOGImageURL + } + + let start = CFAbsoluteTimeGetCurrent() + await store.send(.view(.메타데이터_조회)) + await store.receive(\.inner.메타데이터_조회_수행_반영) { + $0.content.thumbNail = "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + } + let end = CFAbsoluteTimeGetCurrent() + average += end - start + } + + @MainActor + func test_shared_메서드_미적용(_ average: inout CFAbsoluteTime) async { + let store = TestStore(initialState: LegacyContentCardFeature.State( + content: ContentBaseResponse.mock(id: 0).toDomain() + )) { + LegacyContentCardFeature()._printChanges(.actionLabels) + } withDependencies: { + $0[ContentClient.self] = .testValue + let parseOGImageURL: @Sendable ( + _ url: URL + ) async throws -> String? = { _ in + "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + } + + $0[SwiftSoupClient.self].parseOGImageURL = parseOGImageURL + } + + let start = CFAbsoluteTimeGetCurrent() + await store.send(.view(.메타데이터_조회)) + await store.receive(\.async.메타데이터_조회_수행) + await store.receive(\.inner.메타데이터_조회_수행_반영) { + $0.content.thumbNail = "https://i.ytimg.com/vi/wtSwdGJzQCQ/maxresdefault.jpg" + } + await store.receive(\.async.썸네일_수정_API) + let end = CFAbsoluteTimeGetCurrent() + average += end - start } } + + diff --git a/Projects/Feature/FeatureContentCardTests/Sources/LegacyContentCardFeature.swift b/Projects/Feature/FeatureContentCardTests/Sources/LegacyContentCardFeature.swift new file mode 100644 index 00000000..48f33836 --- /dev/null +++ b/Projects/Feature/FeatureContentCardTests/Sources/LegacyContentCardFeature.swift @@ -0,0 +1,184 @@ +// +// LegacyContentCardFeature.swift +// FeatureContentCardTests +// +// Created by 김도형 on 1/17/25. +// + +import SwiftUI + +import ComposableArchitecture +import Domain +import CoreKit +import DSKit +import Util + +@Reducer +public struct LegacyContentCardFeature { + /// - Dependency + @Dependency(SwiftSoupClient.self) + private var swiftSoupClient + @Dependency(\.openURL) + private var openURL + @Dependency(ContentClient.self) + private var contentClient + /// - State + @ObservableState + public struct State: Equatable, Identifiable { + public let id = UUID() + public var content: BaseContentItem + + public init(content: BaseContentItem) { + self.content = content + } + } + + /// - Action + public enum Action: FeatureAction, ViewAction { + case view(View) + case inner(InnerAction) + case async(AsyncAction) + case scope(ScopeAction) + case delegate(DelegateAction) + + @CasePathable + public enum View: Equatable { + case 컨텐츠_항목_눌렀을때 + case 컨텐츠_항목_케밥_버튼_눌렀을때 + case 즐겨찾기_버튼_눌렀을때 + case 메타데이터_조회 + } + + @CasePathable + public enum InnerAction: Equatable { + case 메타데이터_조회_수행_반영(String) + case 즐겨찾기_API_반영(Bool) + } + + @CasePathable + public enum AsyncAction: Equatable { + case 메타데이터_조회_수행 + case 즐겨찾기_API + case 즐겨찾기_취소_API + case 썸네일_수정_API + } + + public enum ScopeAction: Equatable { case doNothing } + + public enum DelegateAction: Equatable { + case 컨텐츠_항목_케밥_버튼_눌렀을때(content: BaseContentItem) + } + } + + /// - Initiallizer + public init() {} + + /// - Reducer Core + private func core(into state: inout State, action: Action) -> Effect { + switch action { + /// - View + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + + /// - Inner + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + + /// - Async + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + + /// - Scope + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + + /// - Delegate + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + } + } + + /// - Reducer body + public var body: some ReducerOf { + Reduce(self.core) + } +} +//MARK: - FeatureAction Effect +private extension LegacyContentCardFeature { + /// - View Effect + func handleViewAction(_ action: Action.View, state: inout State) -> Effect { + switch action { + case .컨텐츠_항목_눌렀을때: + guard let url = URL(string: state.content.data) else { + return .none + } + return .run { _ in await openURL(url) } + case .컨텐츠_항목_케밥_버튼_눌렀을때: + return .send(.delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content: state.content))) + case .메타데이터_조회: + return .send(.async(.메타데이터_조회_수행)) + case .즐겨찾기_버튼_눌렀을때: + guard let isFavorite = state.content.isFavorite else { + return .none + } + UIImpactFeedbackGenerator(style: .light) + .impactOccurred() + return isFavorite + ? .send(.async(.즐겨찾기_취소_API)) + : .send(.async(.즐겨찾기_API)) + } + } + + /// - Inner Effect + func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { + switch action { + case let .메타데이터_조회_수행_반영(imageURL): + state.content.thumbNail = imageURL + return .send(.async(.썸네일_수정_API)) + case .즐겨찾기_API_반영(let favorite): + state.content.isFavorite = favorite + return .none + } + } + + /// - Async Effect + func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { + switch action { + case .메타데이터_조회_수행: + guard let url = URL(string: state.content.data) else { + return .none + } + return .run { send in + let imageURL = try await swiftSoupClient.parseOGImageURL(url) + guard let imageURL else { return } + await send(.inner(.메타데이터_조회_수행_반영(imageURL))) + } + case .즐겨찾기_API: + return .run { [id = state.content.id] send in + let _ = try await contentClient.즐겨찾기("\(id)") + await send(.inner(.즐겨찾기_API_반영(true)), animation: .pokitDissolve) + } + case .즐겨찾기_취소_API: + return .run { [id = state.content.id] send in + try await contentClient.즐겨찾기_취소("\(id)") + await send(.inner(.즐겨찾기_API_반영(false)), animation: .pokitDissolve) + } + case .썸네일_수정_API: + return .run { [content = state.content] _ in + let request = ThumbnailRequest(thumbnail: content.thumbNail) + + try await contentClient.썸네일_수정("\(content.id)", request) + } + } + } + + /// - Scope Effect + func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { + return .none + } + + /// - Delegate Effect + func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { + return .none + } +} diff --git a/Projects/Feature/FeatureContentDetail/Resources/.gitkeep b/Projects/Feature/FeatureContentDetail/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureContentDetail/Resources/Resource.swift b/Projects/Feature/FeatureContentDetail/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureContentDetail/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift index b0247281..425f7a56 100644 --- a/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift +++ b/Projects/Feature/FeatureContentDetail/Sources/ContentDetail/ContentDetailView.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import Domain import DSKit +import Util @ViewAction(for: ContentDetailFeature.self) public struct ContentDetailView: View { @@ -52,11 +53,10 @@ public extension ContentDetailView { .pokitPresentationCornerRadius() .presentationDragIndicator(.visible) .presentationDetents([.height(588), .large]) - .overlay(alignment: .bottom) { - if store.linkPopup != nil { - PokitLinkPopup(type: $store.linkPopup) - } + .overlay(if: store.linkPopup != nil, alignment: .bottom) { + PokitLinkPopup(type: $store.linkPopup) } + .dismissKeyboard(focused: $isFocused) .sheet(isPresented: $store.showAlert) { PokitAlert( "링크를 정말 삭제하시겠습니까?", diff --git a/Projects/Feature/FeatureContentList/Resources/.gitkeep b/Projects/Feature/FeatureContentList/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureContentList/Resources/Resource.swift b/Projects/Feature/FeatureContentList/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureContentList/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift index 48ca2c76..5afb5cdf 100644 --- a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift +++ b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListFeature.swift @@ -120,7 +120,7 @@ public struct ContentListFeature { return handleDelegateAction(delegateAction, state: &state) case let .contents(contentAction): - return .send(.scope(.contents(contentAction))) + return shared(.scope(.contents(contentAction)), state: &state) } } @@ -144,17 +144,18 @@ private extension ContentListFeature { state.domain.pageable.sort = [ state.isListDescending ? "createdAt,desc" : "createdAt,asc" ] - return .send(.inner(.페이징_초기화), animation: .pokitDissolve) + return shared(.inner(.페이징_초기화), state: &state) + .animation(.pokitDissolve) case .dismiss: return .run { _ in await dismiss() } case .뷰가_나타났을때: return .merge( - .send(.async(.컨텐츠_개수_조회_API)), - .send(.async(.컨텐츠_목록_조회_API)), - .send(.async(.클립보드_감지)) + shared(.async(.컨텐츠_개수_조회_API), state: &state), + shared(.async(.컨텐츠_목록_조회_API), state: &state), + shared(.async(.클립보드_감지), state: &state) ) case .pagenation: - return .send(.async(.컨텐츠_목록_조회_페이징_API)) + return shared(.async(.컨텐츠_목록_조회_페이징_API), state: &state) } } @@ -184,7 +185,8 @@ private extension ContentListFeature { state.domain.contentList.data = nil state.isLoading = true state.contents.removeAll() - return .send(.async(.컨텐츠_목록_조회_API), animation: .pokitDissolve) + return shared(.async(.컨텐츠_목록_조회_API), state: &state) + .animation(.pokitDissolve) case let .컨텐츠_개수_업데이트(count): state.domain.contentCount = count return .none @@ -247,7 +249,7 @@ private extension ContentListFeature { /// - 링크에 대한 `공유` / `수정` / `삭제` delegate switch action { case let .contents(.element(id: _, action: .delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content)))): - return .send(.delegate(.링크상세(content: content))) + return shared(.delegate(.링크상세(content: content)), state: &state) case .contents: return .none } @@ -257,7 +259,7 @@ private extension ContentListFeature { func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { switch action { case .컨텐츠_목록_조회: - return .send(.async(.컨텐츠_목록_조회_API)) + return shared(.async(.컨텐츠_목록_조회_API), state: &state) default: return .none } @@ -303,6 +305,24 @@ private extension ContentListFeature { await send(.inner(.컨텐츠_목록_조회_API_반영(contentItems)), animation: .pokitDissolve) } } + + /// - Shared Effect + func shared(_ action: Action, state: inout State) -> Effect { + switch action { + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + case let .contents(contentAction): + return shared(.scope(.contents(contentAction)), state: &state) + } + } } public extension ContentListFeature { diff --git a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift index f468d1bd..d71d2998 100644 --- a/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift +++ b/Projects/Feature/FeatureContentList/Sources/ContentList/ContentListView.swift @@ -58,43 +58,39 @@ private extension ContentListView { } } + @ViewBuilder var list: some View { - Group { - if !store.isLoading { - if store.contents.isEmpty { - PokitCaution(type: .즐겨찾기_링크없음) - .padding(.top, 100) - - Spacer() - } else { - ScrollView { - LazyVStack(spacing: 0) { - ForEach( - Array(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, - type: .linkList, - isFirst: isFirst, - isLast: isLast - ) - } + if !store.isLoading { + if store.contents.isEmpty { + PokitCaution(type: .즐겨찾기_링크없음) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach( + Array(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 - if store.hasNext { - PokitLoading() - .task { await send(.pagenation).finish() } - } + ContentCardView( + store: store, + type: .linkList, + isFirst: isFirst, + isLast: isLast + ) + } + + if store.hasNext { + PokitLoading() + .task { await send(.pagenation).finish() } } - .padding(.horizontal, 20) - .padding(.bottom, 36) } + .padding(.horizontal, 20) + .padding(.bottom, 36) } - } else { - PokitLoading() } + } else { + PokitLoading() } } diff --git a/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift b/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift index 5df9ed37..06fc4f72 100644 --- a/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift +++ b/Projects/Feature/FeatureContentListDemo/Sources/FeatureContentListDemoApp.swift @@ -8,16 +8,23 @@ import SwiftUI import FeatureContentList +import FeatureIntro @main struct FeatureContentListDemoApp: App { var body: some Scene { WindowGroup { // TODO: 루트 뷰 추가 - ContentListView(store: .init( - initialState: .init(contentType: .unread), - reducer: { ContentListFeature()._printChanges() } - )) + + DemoView(store: .init( + initialState: .init(), + reducer: { DemoFeature() } + )) { + ContentListView(store: .init( + initialState: .init(contentType: .favorite), + reducer: { ContentListFeature() } + )) + } } } } diff --git a/Projects/Feature/FeatureContentSetting/Resources/.gitkeep b/Projects/Feature/FeatureContentSetting/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureContentSetting/Resources/Resource.swift b/Projects/Feature/FeatureContentSetting/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureContentSetting/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift index 725c53ac..a10909a1 100644 --- a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift +++ b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift @@ -26,6 +26,8 @@ public struct ContentSettingFeature { private var contentClient @Dependency(CategoryClient.self) private var categoryClient + @Dependency(KeyboardClient.self) + private var keyboardClient /// - State @ObservableState public struct State: Equatable { @@ -70,6 +72,8 @@ public struct ContentSettingFeature { var link: String? var showLinkPreview = false var isShareExtension: Bool + var pokitAddSheetPresented: Bool = false + var isKeyboardVisible: Bool = false } /// - Action @@ -109,6 +113,7 @@ public struct ContentSettingFeature { case 선택한_포킷_인메모리_삭제 case 링크팝업_활성화(PokitLinkPopup.PopupType) case error(Error) + case 키보드_감지_반영(Bool) } public enum AsyncAction: Equatable { @@ -118,6 +123,7 @@ public struct ContentSettingFeature { case 컨텐츠_수정_API case 컨텐츠_추가_API case 클립보드_감지 + case 키보드_감지 } public enum ScopeAction: Equatable { case 없음 } @@ -187,7 +193,8 @@ private extension ContentSettingFeature { var mergeEffect: [Effect] = [ .send(.async(.카테고리_목록_조회_API)), .send(.inner(.URL_유효성_확인)), - .send(.async(.클립보드_감지)) + .send(.async(.클립보드_감지)), + .send(.async(.키보드_감지)) ] if let id = state.domain.contentId { mergeEffect.append(.send(.async(.컨텐츠_상세_조회_API(id: id)))) @@ -209,9 +216,11 @@ private extension ContentSettingFeature { state.linkPopup = .text(title: Constants.포킷_최대_갯수_문구) return .none } - + /// 바텀시트 내리고 `포킷추가하기` depth 추가 + state.pokitAddSheetPresented = false return .send(.delegate(.포킷추가하기)) case .뒤로가기_버튼_눌렀을때: + state.categoryId = nil return state.isShareExtension ? .send(.delegate(.dismiss)) : .run { _ in await dismiss() } @@ -244,9 +253,9 @@ private extension ContentSettingFeature { return .none case .메타데이터_조회_수행(url: let url): return .run { send in - async let title = swiftSoup.parseOGTitle(url) - async let imageURL = swiftSoup.parseOGImageURL(url) - try await send( + async let title = try? swiftSoup.parseOGTitle(url) + async let imageURL = try? swiftSoup.parseOGImageURL(url) + await send( .inner(.메타데이텨_조회_반영(title: title, imageURL: imageURL)), animation: .pokitDissolve ) @@ -304,7 +313,12 @@ private extension ContentSettingFeature { categoryName: category.categoryName, categoryImage: category.categoryImage, contentCount: 0, - createdAt: "" + createdAt: "", + //TODO: v2 property 수정 + openType: .비공개, + keywordType: .default, + userCount: 0, + isFavorite: false ) return .none case .카테고리_목록_조회_API_반영(categoryList: let categoryList): @@ -313,12 +327,12 @@ private extension ContentSettingFeature { /// [1]. `미분류`에 해당하는 인덱스 번호와 항목을 체크, 없다면 목록갱신이 불가함 guard let unclassifiedItemIdx = categoryList.data?.firstIndex(where: { - $0.categoryName == "미분류" + $0.categoryName == Constants.미분류 }) else { return .none } guard let unclassifiedItem = categoryList.data?.first(where: { - $0.categoryName == "미분류" + $0.categoryName == Constants.미분류 }) else { return .none } @@ -348,6 +362,9 @@ private extension ContentSettingFeature { .inner(.링크팝업_활성화(.error(title: errorResponse.message))), animation: .pokitSpring ) + case let .키보드_감지_반영(response): + state.isKeyboardVisible = response + return .none } } @@ -437,6 +454,13 @@ private extension ContentSettingFeature { await send(.inner(.linkPopup(url)), animation: .pokitSpring) } } + + case .키보드_감지: + return .run { send in + for await detect in await keyboardClient.isVisible() { + await send(.inner(.키보드_감지_반영(detect)), animation: .pokitSpring) + } + } } } @@ -452,7 +476,7 @@ private extension ContentSettingFeature { func categoryListFetch(request: BasePageableRequest) -> Effect { return .run { send in - let categoryList = try await categoryClient.카테고리_목록_조회(request, false).toDomain() + let categoryList = try await categoryClient.카테고리_목록_조회(request, false, true).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 eb67c068..e8d33455 100644 --- a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift +++ b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift @@ -54,7 +54,8 @@ public extension ContentSettingView { } .pokitMaxWidth() } - + } + .overlay(alignment: .bottom) { let isDisable = store.urlText.isEmpty || store.title.isEmpty || store.memoTextAreaState == .error(message: "최대 100자까지 입력가능합니다.") @@ -62,13 +63,15 @@ public extension ContentSettingView { PokitBottomButton( "저장하기", state: isDisable ? .disable : .filled(.primary), - isLoading: $store.saveIsLoading, action: { send(.저장_버튼_눌렀을때) } ) - .padding(.horizontal, 20) + .gradientBackground() + .loading($store.saveIsLoading) + .keyboardAnchor(store.isKeyboardVisible) .pokitMaxWidth() } .pokitNavigationBar { navigationBar } + .dismissKeyboard(focused: $focusedType) .ignoresSafeArea(edges: focusedType == nil ? .bottom : []) .onAppear { send(.뷰가_나타났을때) } } @@ -141,6 +144,7 @@ private extension ContentSettingView { var pokitSelectButton: some View { PokitSelect( selectedItem: $store.selectedPokit, + isPresented: $store.pokitAddSheetPresented, label: "포킷", list: store.pokitList, action: { send(.포킷선택_항목_눌렀을때(pokit: $0), animation: .pokitDissolve) }, diff --git a/Projects/Feature/FeatureContentSettingDemo/Sources/FeatureContentSettingDemoApp.swift b/Projects/Feature/FeatureContentSettingDemo/Sources/FeatureContentSettingDemoApp.swift index eb85f291..edaa2abb 100644 --- a/Projects/Feature/FeatureContentSettingDemo/Sources/FeatureContentSettingDemoApp.swift +++ b/Projects/Feature/FeatureContentSettingDemo/Sources/FeatureContentSettingDemoApp.swift @@ -8,21 +8,25 @@ import SwiftUI import FeatureContentSetting +import FeatureIntro @main struct FeatureContentSettingDemoApp: App { var body: some Scene { WindowGroup { - NavigationStack { - ContentSettingView( - store: .init( - initialState: ContentSettingFeature.State( - contentId: 4 - ), - reducer: { ContentSettingFeature()._printChanges() } + DemoView(store: .init( + initialState: DemoFeature.State(), + reducer: { DemoFeature() } + )) { + NavigationStack { + ContentSettingView( + store: .init( + initialState: ContentSettingFeature.State(), + reducer: { ContentSettingFeature()._printChanges() } + ) ) - ) + } } } } diff --git a/Projects/Feature/FeatureCategoryDetail/Resources/Resource.swift b/Projects/Feature/FeatureIntro/Resources/Resource.swift similarity index 100% rename from Projects/Feature/FeatureCategoryDetail/Resources/Resource.swift rename to Projects/Feature/FeatureIntro/Resources/Resource.swift diff --git a/Projects/Feature/FeatureIntro/Sources/Demo/DemoFeature.swift b/Projects/Feature/FeatureIntro/Sources/Demo/DemoFeature.swift new file mode 100644 index 00000000..56e10cc1 --- /dev/null +++ b/Projects/Feature/FeatureIntro/Sources/Demo/DemoFeature.swift @@ -0,0 +1,141 @@ +// +// DemoFeature.swift +// Feature +// +// Created by 김도형 on 12/24/24. + +import ComposableArchitecture +import Util + +@Reducer +public struct DemoFeature { + /// - Dependency + + /// - State + @ObservableState + public enum State { + case intro(IntroFeature.State = .init()) + case main + + public init() { self = .intro() } + } + + /// - Action + public enum Action: FeatureAction, ViewAction { + case view(View) + case inner(InnerAction) + case async(AsyncAction) + case scope(ScopeAction) + case delegate(DelegateAction) + case intro(IntroFeature.Action) + + @CasePathable + public enum View: Equatable { case doNothing } + + public enum InnerAction: Equatable { case doNothing } + + public enum AsyncAction: Equatable { case doNothing } + + public enum ScopeAction { + case intro(IntroFeature.Action) + } + + public enum DelegateAction: Equatable { case doNothing } + } + + /// - Initiallizer + public init() {} + + /// - Reducer Core + private func core(into state: inout State, action: Action) -> Effect { + switch action { + /// - View + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + + /// - Inner + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + + /// - Async + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + + /// - Scope + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + + /// - Delegate + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + + case .intro(let introAction): + return shared(.scope(.intro(introAction)), state: &state) + } + } + + /// - Reducer body + public var body: some ReducerOf { + Reduce(self.core) + .ifCaseLet(\.intro, action: \.intro) { IntroFeature() } + } +} +//MARK: - FeatureAction Effect +private extension DemoFeature { + /// - View Effect + func handleViewAction(_ action: Action.View, state: inout State) -> Effect { + return .none + } + + /// - Inner Effect + func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { + return .none + } + + /// - Async Effect + func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { + return .none + } + + /// - Scope Effect + func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { + switch action { + case .intro(.delegate(.moveToTab)): + state = .main + return .none + case .intro: return .none + } + } + + /// - Delegate Effect + func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { + return .none + } + + func shared(_ action: Action, state: inout State) -> Effect { + switch action { + /// - View + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + + /// - Inner + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + + /// - Async + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + + /// - Scope + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + + /// - Delegate + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + + case .intro(let introAction): + return shared(.scope(.intro(introAction)), state: &state) + } + } +} diff --git a/Projects/Feature/FeatureIntro/Sources/Demo/DemoView.swift b/Projects/Feature/FeatureIntro/Sources/Demo/DemoView.swift new file mode 100644 index 00000000..6f74edd7 --- /dev/null +++ b/Projects/Feature/FeatureIntro/Sources/Demo/DemoView.swift @@ -0,0 +1,61 @@ +// +// DemoView.swift +// Feature +// +// Created by 김도형 on 12/24/24. + +import ComposableArchitecture +import SwiftUI + +@ViewAction(for: DemoFeature.self) +public struct DemoView: View { + /// - Properties + public let store: StoreOf + + @ViewBuilder + private let mainView: T + + /// - Initializer + public init( + store: StoreOf, + @ViewBuilder mainView: () -> T + ) { + self.store = store + self.mainView = mainView() + } +} +//MARK: - View +public extension DemoView { + var body: some View { + WithPerceptionTracking { + Group { + switch store.state { + case .intro: + if let store = store.scope(state: \.intro, action: \.intro) { + IntroView(store: store) + } + case .main: + mainView + } + } + .background(.pokit(.bg(.base))) + .ignoresSafeArea(edges: .bottom) + .animation(.smooth, value: store) + } + } +} +//MARK: - Configure View +private extension DemoView { + +} +//MARK: - Preview +#Preview { + DemoView(store: Store( + initialState: .init(), + reducer: { DemoFeature() } + )) { + + } +} + + diff --git a/Projects/Feature/FeatureLogin/Sources/Intro/IntroFeature.swift b/Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift similarity index 99% rename from Projects/Feature/FeatureLogin/Sources/Intro/IntroFeature.swift rename to Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift index 584b66be..6841a3eb 100644 --- a/Projects/Feature/FeatureLogin/Sources/Intro/IntroFeature.swift +++ b/Projects/Feature/FeatureIntro/Sources/Intro/IntroFeature.swift @@ -5,6 +5,7 @@ // Created by 김민호 on 7/11/24. import ComposableArchitecture +import FeatureLogin import CoreKit @Reducer diff --git a/Projects/Feature/FeatureLogin/Sources/Intro/IntroView.swift b/Projects/Feature/FeatureIntro/Sources/Intro/IntroView.swift similarity index 98% rename from Projects/Feature/FeatureLogin/Sources/Intro/IntroView.swift rename to Projects/Feature/FeatureIntro/Sources/Intro/IntroView.swift index 4d0ef886..74e865e9 100644 --- a/Projects/Feature/FeatureLogin/Sources/Intro/IntroView.swift +++ b/Projects/Feature/FeatureIntro/Sources/Intro/IntroView.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import FeatureLogin public struct IntroView: View { /// - Properties diff --git a/Projects/Feature/FeatureLogin/Sources/Splash/SplashFeature.swift b/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift similarity index 100% rename from Projects/Feature/FeatureLogin/Sources/Splash/SplashFeature.swift rename to Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift diff --git a/Projects/Feature/FeatureLogin/Sources/Splash/SplashView.swift b/Projects/Feature/FeatureIntro/Sources/Splash/SplashView.swift similarity index 100% rename from Projects/Feature/FeatureLogin/Sources/Splash/SplashView.swift rename to Projects/Feature/FeatureIntro/Sources/Splash/SplashView.swift diff --git a/Projects/Feature/FeatureRemindDemo/Resources/LaunchScreen.storyboard b/Projects/Feature/FeatureIntroDemo/Resources/LaunchScreen.storyboard similarity index 100% rename from Projects/Feature/FeatureRemindDemo/Resources/LaunchScreen.storyboard rename to Projects/Feature/FeatureIntroDemo/Resources/LaunchScreen.storyboard diff --git a/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift b/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift new file mode 100644 index 00000000..31c4cda1 --- /dev/null +++ b/Projects/Feature/FeatureIntroDemo/Sources/FeatureIntroDemoApp.swift @@ -0,0 +1,17 @@ +// +// App.stencil.swift +// ProjectDescriptionHelpers +// +// Created by 김도형 on 6/16/24. +// + +import SwiftUI + +@main +struct FeatureIntroDemoApp: App { + var body: some Scene { + WindowGroup { + // TODO: 루트 뷰 추가 + } + } +} diff --git a/Projects/Feature/FeatureRemindTests/Resources/info.plist b/Projects/Feature/FeatureIntroTests/Resources/info.plist similarity index 100% rename from Projects/Feature/FeatureRemindTests/Resources/info.plist rename to Projects/Feature/FeatureIntroTests/Resources/info.plist diff --git a/Projects/Feature/FeatureRemindTests/Sources/FeatureRemindTests.swift b/Projects/Feature/FeatureIntroTests/Sources/FeatureIntroTests.swift similarity index 50% rename from Projects/Feature/FeatureRemindTests/Sources/FeatureRemindTests.swift rename to Projects/Feature/FeatureIntroTests/Sources/FeatureIntroTests.swift index 3690595d..c0aec3ce 100644 --- a/Projects/Feature/FeatureRemindTests/Sources/FeatureRemindTests.swift +++ b/Projects/Feature/FeatureIntroTests/Sources/FeatureIntroTests.swift @@ -1,9 +1,9 @@ import ComposableArchitecture import XCTest -@testable import FeatureRemind +@testable import FeatureIntro -final class FeatureRemindTests: XCTestCase { +final class FeatureIntroTests: XCTestCase { func test() { } diff --git a/Projects/Feature/FeatureLogin/Resources/.gitkeep b/Projects/Feature/FeatureLogin/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureLogin/Resources/Resource.swift b/Projects/Feature/FeatureLogin/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureLogin/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameFeature.swift b/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameFeature.swift index ee5bf4df..772cfd98 100644 --- a/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameFeature.swift +++ b/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameFeature.swift @@ -16,6 +16,7 @@ public struct RegisterNicknameFeature { @Dependency(\.dismiss) var dismiss @Dependency(\.mainQueue) var mainQueue @Dependency(UserClient.self) var userClient + @Dependency(KeyboardClient.self) var keyboardClient /// - State @ObservableState public struct State: Equatable { @@ -32,6 +33,7 @@ public struct RegisterNicknameFeature { } var buttonActive: Bool = false var textfieldState: PokitInputStyle.State = .default + var isKeyboardVisible: Bool = false } /// - Action public enum Action: FeatureAction, ViewAction { @@ -47,15 +49,18 @@ public struct RegisterNicknameFeature { /// - Button Tapped case 다음_버튼_눌렀을때 case dismiss + case onAppear } public enum InnerAction: Equatable { case 닉네임_텍스트_변경되었을때 case 닉네임_중복_체크_API_반영(Bool) + case 키보드_감지_반영(Bool) } public enum AsyncAction: Equatable { case 닉네임_중복_체크_API + case 키보드_감지 } public enum ScopeAction: Equatable { case 없음 } public enum DelegateAction: Equatable { @@ -110,6 +115,8 @@ private extension RegisterNicknameFeature { ) case .binding: return .none + case .onAppear: + return .send(.async(.키보드_감지)) } } /// - Inner Effect @@ -146,6 +153,10 @@ private extension RegisterNicknameFeature { state.buttonActive = true } return .none + + case let .키보드_감지_반영(response): + state.isKeyboardVisible = response + return .none } } /// - Async Effect @@ -156,6 +167,13 @@ private extension RegisterNicknameFeature { let result = try await userClient.닉네임_중복_체크(nickName) await send(.inner(.닉네임_중복_체크_API_반영(result.isDuplicate))) } + + case .키보드_감지: + return .run { send in + for await detect in await keyboardClient.isVisible() { + await send(.inner(.키보드_감지_반영(detect)), animation: .pokitSpring) + } + } } } /// - Scope Effect diff --git a/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameView.swift b/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameView.swift index 6ce0890f..483625ce 100644 --- a/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameView.swift +++ b/Projects/Feature/FeatureLogin/Sources/RegisterNickname/RegisterNicknameView.swift @@ -45,7 +45,7 @@ public extension RegisterNicknameView { : .disable, action: { send(.다음_버튼_눌렀을때) } ) - .setKeyboardHeight() + .keyboardAnchor(store.isKeyboardVisible) } .pokitMaxWidth() .padding(.horizontal, 20) @@ -58,7 +58,9 @@ public extension RegisterNicknameView { } } } + .dismissKeyboard(focused: $isFocused) .ignoresSafeArea(edges: .bottom) + .onAppear { send(.onAppear) } } } } diff --git a/Projects/Feature/FeatureMyFolder/Resources/.gitkeep b/Projects/Feature/FeatureMyFolder/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureMyFolder/Resources/Resource.swift b/Projects/Feature/FeatureMyFolder/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureMyFolder/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeatureMyPage/Resources/.gitkeep b/Projects/Feature/FeatureMyPage/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureMyPage/Resources/Resource.swift b/Projects/Feature/FeatureMyPage/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureMyPage/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeaturePokit/Resources/.gitkeep b/Projects/Feature/FeaturePokit/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeaturePokit/Resources/Resource.swift b/Projects/Feature/FeaturePokit/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeaturePokit/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeaturePokit/Sources/PokitFavoriteCard.swift b/Projects/Feature/FeaturePokit/Sources/PokitFavoriteCard.swift new file mode 100644 index 00000000..9d6b783e --- /dev/null +++ b/Projects/Feature/FeaturePokit/Sources/PokitFavoriteCard.swift @@ -0,0 +1,92 @@ +// +// PokitFavoriteCard.swift +// FeaturePokit +// +// Created by 김민호 on 1/6/25. +// + +import SwiftUI + +public struct PokitFavoriteCard: View { + private let linkCount: Int + private let action: () -> Void + + public init( + linkCount: Int, + action: @escaping () -> Void + ) { + self.linkCount = linkCount + self.action = action + } + + public var body: some View { + Button(action: action) { + buttonLabel + } + } + + private var buttonLabel: some View { + VStack(spacing: 0) { + HStack { + title + + Spacer(minLength: 28) + } + .overlay(alignment: .trailing) { + Image(.icon(.tack)) + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle(.pokit(.icon(.primary))) + } + + HStack { + subTitle + + Spacer() + } + .padding(.top, 2) + + Spacer() + + HStack { + Spacer() + + thumbNail + } + } + .padding([.top, .leading], 12) + .padding([.bottom, .trailing], 8) + .background { + background + } + .frame(height: 152) + } + + private var title: some View { + Text("즐겨찾기") + .pokitFont(.b1(.b)) + .foregroundStyle(.pokit(.text(.primary))) + } + + private var subTitle: some View { + Text("링크 \(linkCount)개") + .pokitFont(.detail2) + .foregroundStyle(.pokit(.text(.tertiary))) + .contentTransition(.numericText()) + } + + private var thumbNail: some View { + Image(.character(.pooki)) + .resizable() + .frame(width: 84, height: 84) + } + + private var background: some View { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.pokit(.bg(.brand)).opacity(0.1)) + } +} + +#Preview { + PokitFavoriteCard(linkCount: 3, action: {}) +} diff --git a/Projects/Feature/FeaturePokit/Sources/PokitLinkEditFeature.swift b/Projects/Feature/FeaturePokit/Sources/PokitLinkEditFeature.swift index 23412a9d..61a291ce 100644 --- a/Projects/Feature/FeaturePokit/Sources/PokitLinkEditFeature.swift +++ b/Projects/Feature/FeaturePokit/Sources/PokitLinkEditFeature.swift @@ -32,6 +32,7 @@ public struct PokitLinkEditFeature { var list = IdentifiedArrayOf() /// 선택한 링크 목록 var selectedItems = IdentifiedArrayOf() + var isActive: Bool = false /// 포킷 이동 눌렀을 때 sheet var categorySelectSheetPresetend: Bool = false var linkDeleteSheetPresented: Bool = false @@ -85,7 +86,7 @@ public struct PokitLinkEditFeature { } public enum DelegateAction: Equatable { - case 링크_편집_종료(items: [BaseContentItem]) + case 링크_편집_종료(items: [BaseContentItem], type: LinkEditType) } } @@ -135,8 +136,7 @@ private extension PokitLinkEditFeature { return .none case .dismiss: - return .send(.delegate(.링크_편집_종료(items: state.list.elements))) -// return .run { _ in await dismiss() } + return .send(.delegate(.링크_편집_종료(items: state.list.elements, type: .dismiss))) case .뷰가_나타났을때: return fetchCateogryList() @@ -160,6 +160,8 @@ private extension PokitLinkEditFeature { } else { state.selectedItems.append(item) } + + state.isActive = !state.selectedItems.isEmpty return .none case let .카테고리_선택했을때(pokit): @@ -170,7 +172,7 @@ private extension PokitLinkEditFeature { .send(.inner(.경고팝업_활성화(.error(title: "링크를 선택해주세요.")))) ) } else { - return moveContentList(categoryId: pokit.id, state: &state) + return moveContentList(category: pokit, state: &state) } case .링크팝업_버튼_눌렀을때: @@ -212,7 +214,7 @@ private extension PokitLinkEditFeature { case let .미분류_API_반영(type): /// 1. 시트 내리기 - if type == .링크이동 { + if case .링크이동 = type { state.categorySelectSheetPresetend = false } else { state.linkDeleteSheetPresented = false @@ -222,10 +224,16 @@ private extension PokitLinkEditFeature { .map { $0.id } .forEach { state.list.remove(id: $0) } state.selectedItems.removeAll() - + /// 3. 분류가 남은 링크가 없을 때 편집하기 종료 if state.list.isEmpty { - return .send(.delegate(.링크_편집_종료(items: []))) + return .send(.delegate(.링크_편집_종료(items: [], type: type))) } + /// 4. 링크이동을 했을 때 바텀 메세지 출력 + if case .링크이동 = type { + state.linkPopup = .text(title: "링크 이동이 완료되었습니다.") + return .none + } + return .none } } @@ -249,10 +257,12 @@ private extension PokitLinkEditFeature { case .전체선택_버튼_눌렀을때: state.selectedItems = state.list + state.isActive = !state.selectedItems.isEmpty return .none case .전체해제_버튼_눌렀을때: state.selectedItems.removeAll() + state.isActive = !state.selectedItems.isEmpty return .none case .포킷이동_버튼_눌렀을때: @@ -280,16 +290,16 @@ private extension PokitLinkEditFeature { func fetchCateogryList() -> Effect { return .run { send in let request: BasePageableRequest = BasePageableRequest(page: 0, size: 100, sort: ["createdAt", "desc"]) - let response = try await categoryClient.카테고리_목록_조회(request, false).toDomain() + let response = try await categoryClient.카테고리_목록_조회(request, true, true).toDomain() await send(.inner(.카테고리_목록_조회_API_반영(response))) } } /// 미분류 링크 카테고리 이동 API - func moveContentList(categoryId: Int, state: inout State) -> Effect { + func moveContentList(category: BaseCategoryItem, state: inout State) -> Effect { return .run { [contentIds = state.selectedItems] send in let contentIds = contentIds.map { $0.id } - let request = ContentMoveRequest(contentIds: contentIds, categoryId: categoryId) + let request = ContentMoveRequest(contentIds: contentIds, categoryId: category.id) try await contentClient.미분류_링크_포킷_이동(request) await send(.inner(.미분류_API_반영(.링크이동))) } catch: { error, send in @@ -309,6 +319,7 @@ private extension PokitLinkEditFeature { } public extension PokitLinkEditFeature { enum LinkEditType: Equatable { + case dismiss case 링크이동 case 링크삭제 } diff --git a/Projects/Feature/FeaturePokit/Sources/PokitLinkEditView.swift b/Projects/Feature/FeaturePokit/Sources/PokitLinkEditView.swift index 1472455f..1c6b7e56 100644 --- a/Projects/Feature/FeaturePokit/Sources/PokitLinkEditView.swift +++ b/Projects/Feature/FeaturePokit/Sources/PokitLinkEditView.swift @@ -123,6 +123,7 @@ private extension PokitLinkEditView { var actionFloatButtonView: some View { PokitLinkEditFloatView( + isActive: $store.isActive, delegateSend: { store.send(.scope(.floatButtonAction($0))) } ) } diff --git a/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift b/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift index 05bbd626..7a754755 100644 --- a/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift +++ b/Projects/Feature/FeaturePokit/Sources/PokitRootFeature.swift @@ -20,6 +20,8 @@ public struct PokitRootFeature { private var categoryClient @Dependency(ContentClient.self) private var contentClient + @Dependency(RemindClient.self) + private var remindClient @Dependency(KakaoShareClient.self) private var kakaoShareClient /// - State @@ -123,6 +125,7 @@ public struct PokitRootFeature { case settingButtonTapped case categoryTapped(BaseCategoryItem) + case linkPopup(text: String) case 수정하기(BaseCategoryItem) case 링크수정하기(id: Int) /// 링크상세로 이동 @@ -375,7 +378,7 @@ private extension PokitRootFeature { state.domain.pageable.page += 1 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() + let classified = try await categoryClient.카테고리_목록_조회(request, true, false).toDomain() await send(.inner(.카테고리_페이징_조회_API_반영(contentList: classified))) } @@ -398,7 +401,7 @@ private extension PokitRootFeature { state.domain.pageable.page = 0 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() + let classified = try await categoryClient.카테고리_목록_조회(request, true, false).toDomain() await send(.inner(.카테고리_조회_API_반영(categoryList: classified)), animation: .pokitSpring) } @@ -443,7 +446,8 @@ private extension PokitRootFeature { size: pageable.size, sort: pageable.sort ), - true + true, + false ).toDomain() continuation.yield(categoryList) } @@ -520,7 +524,7 @@ private extension PokitRootFeature { case .contents: return .none - case let .linkEdit(.presented(.delegate(.링크_편집_종료(list)))): + case let .linkEdit(.presented(.delegate(.링크_편집_종료(list, type)))): /// 링크가 비어있을때는 전부 삭제 if list.isEmpty { state.contents.removeAll() @@ -538,6 +542,11 @@ private extension PokitRootFeature { state.contents = linkIds } state.linkEdit = nil + + if case .링크이동 = type { + let text = "링크 이동이 완료되었습니다." + return .send(.delegate(.linkPopup(text: text))) + } return .none case .linkEdit: diff --git a/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift b/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift index 498a48cd..f5771274 100644 --- a/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift +++ b/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift @@ -121,34 +121,28 @@ private extension PokitRootView { Group { if store.folderType == .folder(.포킷) { pokitView + .padding(.top, 20) } else { unclassifiedView } } - .padding(.top, 20) .scrollIndicators(.hidden) .animation(.pokitDissolve, value: store.folderType) } + @ViewBuilder var pokitView: some View { - Group { - if let categories = store.categories { - if categories.isEmpty { - VStack { - PokitCaution( - type: .카테고리없음, - action: { send(.포킷추가_버튼_눌렀을때) } - ) - .padding(.top, 36) - - Spacer() - } - } else { - pokitList(categories) - } + if let categories = store.categories { + if categories.isEmpty { + PokitCaution( + type: .카테고리없음, + action: { send(.포킷추가_버튼_눌렀을때) } + ) } else { - PokitLoading() + pokitList(categories) } + } else { + PokitLoading() } } @@ -158,11 +152,19 @@ private extension PokitRootView { LazyVStack(spacing: 0) { LazyVGrid(columns: column, spacing: 12) { ForEach(categories, id: \.id) { item in - PokitCard( - category: item, - action: { send(.카테고리_눌렀을때(item)) }, - kebabAction: { send(.케밥_버튼_눌렀을때(item)) } - ) + if item.isFavorite { + PokitFavoriteCard( + linkCount: item.contentCount, + action: { send(.카테고리_눌렀을때(item)) } + ) + } + else { + PokitCard( + category: item, + action: { send(.카테고리_눌렀을때(item)) }, + kebabAction: { send(.케밥_버튼_눌렀을때(item)) } + ) + } } } @@ -175,25 +177,20 @@ private extension PokitRootView { } } + @ViewBuilder var unclassifiedView: some View { - Group { - if !store.isLoading { - if store.contents.isEmpty { - VStack { - PokitCaution( - type: .미분류_링크없음, - action: { send(.링크추가_버튼_눌렀을때) } - ) - .padding(.top, 36) - - Spacer() - } - } else { - unclassifiedList - } + if !store.isLoading { + if store.contents.isEmpty { + PokitCaution( + type: .미분류_링크없음, + action: { send(.링크추가_버튼_눌렀을때) } + ) } else { - PokitLoading() + unclassifiedList + .padding(.top, 20) } + } else { + PokitLoading() } } diff --git a/Projects/Feature/FeaturePokit/Sources/Sheet/PokitLinkEditFloatView.swift b/Projects/Feature/FeaturePokit/Sources/Sheet/PokitLinkEditFloatView.swift index 98fc7899..5b94da51 100644 --- a/Projects/Feature/FeaturePokit/Sources/Sheet/PokitLinkEditFloatView.swift +++ b/Projects/Feature/FeaturePokit/Sources/Sheet/PokitLinkEditFloatView.swift @@ -11,11 +11,14 @@ import SwiftUI public struct PokitLinkEditFloatView: View { /// 전체 선택/해제 toggle @State private var isChecked: Bool = false + @Binding private var isActive: Bool private let delegateSend: ((PokitLinkEditFloatView.Delegate) -> Void)? public init( + isActive: Binding, delegateSend: ((PokitLinkEditFloatView.Delegate) -> Void)? ) { + self._isActive = isActive self.delegateSend = delegateSend } @@ -28,8 +31,10 @@ public struct PokitLinkEditFloatView: View { button(isChecked ? .전체해제 : .전체선택) Spacer() button(.링크삭제) + .disabled(!isActive) Spacer() button(.포킷이동) + .disabled(!isActive) } .padding(.horizontal, 20) } @@ -41,6 +46,7 @@ public struct PokitLinkEditFloatView: View { color: Color.black, colorPercent: 10 ) + .animation(.pokitSpring, value: isActive) } } private extension PokitLinkEditFloatView { @@ -107,5 +113,8 @@ public extension PokitLinkEditFloatView { } } #Preview { - PokitLinkEditFloatView(delegateSend: {_ in }).padding(20) + PokitLinkEditFloatView( + isActive: .constant(true), + delegateSend: {_ in } + ).padding(20) } diff --git a/Projects/Feature/FeatureRecommend/Resources/.gitkeep b/Projects/Feature/FeatureRecommend/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift new file mode 100644 index 00000000..9f3376db --- /dev/null +++ b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift @@ -0,0 +1,443 @@ +// +// RecommendFeature.swift +// Feature +// +// Created by 김도형 on 1/29/25. + +import SwiftUI + +import ComposableArchitecture +import Domain +import CoreKit +import Util +import DSKit + +@Reducer +public struct RecommendFeature { + /// - Dependency + @Dependency(ContentClient.self) + private var contentClient + @Dependency(UserClient.self) + private var userClient + @Dependency(CategoryClient.self) + private var categoryClient + @Dependency(\.openURL) + private var openURL + /// - State + @ObservableState + public struct State: Equatable { + public init() {} + + fileprivate var domain = Recommend() + var isListDescending = true + /// pagenation + var hasNext: Bool { + domain.contentList.hasNext + } + var recommendedList: IdentifiedArrayOf? { + guard let list = domain.contentList.data else { return nil } + var array = IdentifiedArrayOf() + array.append(contentsOf: list) + return array + } + var myInterestList: IdentifiedArrayOf { + var array = IdentifiedArrayOf() + array.append(contentsOf: domain.myInterests) + return array + } + var pokitList: [BaseCategoryItem]? { + get { domain.categoryListInQuiry.data } + } + var isLoading: Bool = true + var selectedInterest: BaseInterest? + var shareContent: BaseContentItem? + var interests: [BaseInterest] { domain.interests } + var showKeywordSheet: Bool = false + var selectedInterestList = Set() + var reportContent: BaseContentItem? + var showSelectSheet: Bool = false + var selectedPokit: BaseCategoryItem? + var addContent: BaseContentItem? + } + + /// - Action + public enum Action: FeatureAction, ViewAction { + case view(View) + case inner(InnerAction) + case async(AsyncAction) + case scope(ScopeAction) + case delegate(DelegateAction) + + @CasePathable + public enum View: BindableAction { + /// - Binding + case binding(BindingAction) + + case onAppear + case pagination + + case 추가하기_버튼_눌렀을때(BaseContentItem) + case 공유하기_버튼_눌렀을때(BaseContentItem) + case 신고하기_버튼_눌렀을때(BaseContentItem) + case 신고하기_확인_버튼_눌렀을때(BaseContentItem) + case 전체보기_버튼_눌렀을때(ScrollViewProxy) + case 관심사_버튼_눌렀을때(BaseInterest, ScrollViewProxy) + case 관심사_편집_버튼_눌렀을때 + case 키워드_선택_버튼_눌렀을때(Set) + case 링크_공유_완료되었을때 + case 검색_버튼_눌렀을때 + case 알림_버튼_눌렀을때 + case 추천_컨텐츠_눌렀을때(String) + case 경고시트_dismiss + case 포킷선택_항목_눌렀을때(pokit: BaseCategoryItem) + case 포킷_추가하기_버튼_눌렀을때 + } + + public enum InnerAction { + case 추천_조회_API_반영(BaseContentListInquiry) + case 추천_조회_페이징_API_반영(BaseContentListInquiry) + case 유저_관심사_조회_API_반영([BaseInterest]) + case 관심사_조회_API_반영([BaseInterest]) + case 컨텐츠_신고_API_반영(Int) + case 카테고리_목록_조회_API_반영(categoryList: BaseCategoryListInquiry) + } + + public enum AsyncAction: Equatable { + case 추천_조회_API + case 추천_조회_페이징_API + case 유저_관심사_조회_API + case 관심사_조회_API + case 컨텐츠_신고_API(Int) + case 카테고리_목록_조회_API + case 컨텐츠_추가_API + } + + public enum ScopeAction: Equatable { case doNothing } + + public enum DelegateAction: Equatable { + case 저장하기_완료 + case 검색_버튼_눌렀을때 + case 알림_버튼_눌렀을때 + case 컨텐츠_신고_API_반영 + case 포킷_추가하기_버튼_눌렀을때 + case 포킷_추가하기_완료 + } + } + + /// - Initiallizer + public init() {} + + /// - Reducer Core + private func core(into state: inout State, action: Action) -> Effect { + switch action { + /// - View + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + + /// - Inner + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + + /// - Async + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + + /// - Scope + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + + /// - Delegate + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + } + } + + /// - Reducer body + public var body: some ReducerOf { + BindingReducer(action: \.view) + + Reduce(self.core) + } +} +//MARK: - FeatureAction Effect +private extension RecommendFeature { + /// - View Effect + func handleViewAction(_ action: Action.View, state: inout State) -> Effect { + switch action { + case .binding: return .none + case .onAppear: + return .merge( + shared(.async(.추천_조회_API), state: &state), + shared(.async(.유저_관심사_조회_API), state: &state) + ) + case .pagination: + return shared(.async(.추천_조회_페이징_API), state: &state) + case let .추가하기_버튼_눌렀을때(content): + state.addContent = content + state.showSelectSheet = true + return shared(.async(.카테고리_목록_조회_API), state: &state) + case let .공유하기_버튼_눌렀을때(content): + state.shareContent = content + return .none + case let .신고하기_확인_버튼_눌렀을때(content): + state.reportContent = nil + return shared(.async(.컨텐츠_신고_API(content.id)), state: &state) + case let .신고하기_버튼_눌렀을때(content): + state.reportContent = content + return .none + case let .전체보기_버튼_눌렀을때(proxy): + guard state.selectedInterest != nil else { return .none } + state.domain.contentList.data = nil + + state.selectedInterest = nil + let leading = 20 / UIScreen.main.bounds.width + let anchor = UnitPoint( + x: leading, + y: UnitPoint.leading.y + ) + proxy.scrollTo("전체보기", anchor: anchor) + return shared(.async(.추천_조회_API), state: &state) + case let .관심사_버튼_눌렀을때(interest, proxy): + guard state.selectedInterest != interest else { return .none } + state.domain.contentList.data = nil + + state.selectedInterest = interest + proxy.scrollTo(interest.description, anchor: .leading) + return shared(.async(.추천_조회_API), state: &state) + case .링크_공유_완료되었을때: + state.shareContent = nil + return .none + case .검색_버튼_눌렀을때: + return .send(.delegate(.검색_버튼_눌렀을때)) + case .알림_버튼_눌렀을때: + return .send(.delegate(.알림_버튼_눌렀을때)) + case let .추천_컨텐츠_눌렀을때(urlString): + guard let url = URL(string: urlString) else { return .none } + return .run { _ in await openURL(url) } + case .관심사_편집_버튼_눌렀을때: + return shared(.async(.관심사_조회_API), state: &state) + case let .키워드_선택_버튼_눌렀을때(interests): + state.showKeywordSheet = false + state.selectedInterest = nil + state.selectedInterestList = interests + return .run { [ interests = state.selectedInterestList ] send in + let request = InterestRequest(interests: interests.map(\.description)) + try await userClient.관심사_수정(model: request) + await send(.async(.유저_관심사_조회_API)) + await send(.async(.추천_조회_API)) + } + case .경고시트_dismiss: + state.reportContent = nil + return .none + case .포킷선택_항목_눌렀을때(pokit: let pokit): + state.selectedPokit = pokit + state.showSelectSheet = false + return shared(.async(.컨텐츠_추가_API), state: &state) + case .포킷_추가하기_버튼_눌렀을때: + state.showSelectSheet = false + return .send(.delegate(.포킷_추가하기_버튼_눌렀을때)) + } + } + + /// - Inner Effect + func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { + switch action { + case .추천_조회_페이징_API_반영(let contentList): + let list = state.domain.contentList.data ?? [] + guard let newList = contentList.data else { return .none } + + state.domain.contentList = contentList + state.domain.contentList.data = list + newList + return .none + case .추천_조회_API_반영(let contentList): + state.domain.contentList = contentList + + state.isLoading = false + return .none + case let .유저_관심사_조회_API_반영(interests): + state.domain.myInterests = interests + interests.forEach { state.selectedInterestList.insert($0) } + return .none + case let .관심사_조회_API_반영(interests): + state.domain.interests = interests.filter({ interest in + interest.code != "default" + }) + state.showKeywordSheet = true + return .none + case let .컨텐츠_신고_API_반영(contentId): + state.domain.contentList.data?.removeAll(where: { $0.id == contentId }) + return .send(.delegate(.컨텐츠_신고_API_반영)) + case .카테고리_목록_조회_API_반영(categoryList: let categoryList): + /// - `카테고리_목록_조회`의 filter 옵션을 `false`로 해두었기 때문에 `미분류` 카테고리 또한 항목에서 조회가 가능함 + + /// [1]. `미분류`에 해당하는 인덱스 번호와 항목을 체크, 없다면 목록갱신이 불가함 + guard + let unclassifiedItemIdx = categoryList.data?.firstIndex(where: { + $0.categoryName == Constants.미분류 + }) + else { return .none } + guard + let unclassifiedItem = categoryList.data?.first(where: { + $0.categoryName == Constants.미분류 + }) + else { return .none } + + /// [2]. 새로운 list변수를 만들어주고 카테고리 항목 순서를 재배치 (최신순 정렬 시 미분류는 항상 맨 마지막) + var list = categoryList + list.data?.remove(at: unclassifiedItemIdx) + list.data?.insert(unclassifiedItem, at: 0) + + /// [3]. 도메인 항목 리스트에 list 할당 + state.domain.categoryListInQuiry = list + state.selectedPokit = unclassifiedItem + return .none + } + } + + /// - Async Effect + func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { + switch action { + case .추천_조회_페이징_API: + state.domain.pageable.page += 1 + return .run { [ + pageable = state.domain.pageable, + keyword = state.selectedInterest?.description + ] send in + let pageableRequest = BasePageableRequest( + page: pageable.page, + size: pageable.size, + sort: pageable.sort + ) + let contentList = try await contentClient.추천_컨텐츠_조회( + pageableRequest, + keyword + ).toDomain() + + await send(.inner(.추천_조회_페이징_API_반영(contentList))) + } + case .추천_조회_API: + return contentListFetch(state: &state) + case .유저_관심사_조회_API: + return .run { send in + let interests = try await userClient.유저_관심사_목록_조회().map { $0.toDomian() } + await send(.inner(.유저_관심사_조회_API_반영(interests))) + } + case .관심사_조회_API: + return .run { send in + let interests = try await userClient.관심사_목록_조회().map { $0.toDomian() } + await send(.inner(.관심사_조회_API_반영(interests))) + } + case let .컨텐츠_신고_API(contentId): + return .run { send in + try await contentClient.컨텐츠_신고(contentId: contentId) + await send( + .inner(.컨텐츠_신고_API_반영(contentId)), + animation: .pokitSpring + ) + } + case .카테고리_목록_조회_API: + let request = BasePageableRequest( + page: state.domain.pageable.page, + size: 30, + sort: state.domain.pageable.sort + ) + return categoryListFetch(request: request) + case .컨텐츠_추가_API: + guard + let categoryId = state.selectedPokit?.id, + let category = state.domain.categoryListInQuiry.data?.first(where: { + $0.id == categoryId + }), + let content = state.addContent + else { return .none } + let request = ContentBaseRequest( + data: content.data, + title: content.title, + categoryId: categoryId, + memo: content.memo ?? "", + alertYn: "NO", + thumbNail: content.thumbNail + ) + return .run { send in + let content = try await contentClient.컨텐츠_추가(request) + await send(.delegate(.저장하기_완료)) + } + } + } + + /// - Scope Effect + func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { + return .none + } + + /// - Delegate Effect + func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { + switch action { + case .포킷_추가하기_완료: + guard state.addContent != nil else { return .none } + state.showSelectSheet = true + return shared(.async(.카테고리_목록_조회_API), state: &state) + case .저장하기_완료: + state.addContent = nil + return .none + default: return .none + } + } + + /// - Shared Effect + func shared(_ action: Action, state: inout State) -> Effect { + switch action { + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + } + } + + func contentListFetch(state: inout State) -> Effect { + return .run { [ + pageable = state.domain.pageable, + keyword = state.selectedInterest?.description + ] send in + let stream = AsyncThrowingStream { continuation in + Task { + for page in 0...pageable.page { + let pageableRequest = BasePageableRequest( + page: page, + size: pageable.size, + sort: pageable.sort + ) + let contentList = try await contentClient.추천_컨텐츠_조회( + pageableRequest, + keyword + ).toDomain() + continuation.yield(contentList) + } + continuation.finish() + } + } + var contentItems: BaseContentListInquiry? = nil + for try await contentList in stream { + let items = contentItems?.data ?? [] + let newItems = contentList.data ?? [] + contentItems = contentList + contentItems?.data = items + newItems + } + guard let contentItems else { return } + await send(.inner(.추천_조회_API_반영(contentItems)), animation: .pokitDissolve) + } + } + + func categoryListFetch(request: BasePageableRequest) -> Effect { + return .run { send in + let categoryList = try await categoryClient.카테고리_목록_조회(request, false, true).toDomain() + await send(.inner(.카테고리_목록_조회_API_반영(categoryList: categoryList)), animation: .pokitDissolve) + } + } +} diff --git a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendKeywordBottomSheet.swift b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendKeywordBottomSheet.swift new file mode 100644 index 00000000..3963338c --- /dev/null +++ b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendKeywordBottomSheet.swift @@ -0,0 +1,149 @@ +// +// RecommendKeywordBottomSheet.swift +// FeatureRecommend +// +// Created by 김도형 on 2/22/25. +// + +import SwiftUI + +import Domain +import DSKit +import Util + +struct RecommendKeywordBottomSheet: View { + @State + private var selectedInterests: Set + @State + private var height: CGFloat = 0 + @State + private var isTouchActive: Bool = false + + private let interests: [BaseInterest] + private let onSave: ((Set) -> Void)? + + private init( + selectedInterests: Set, + interests: [BaseInterest], + onSave: ((Set) -> Void)? + ) { + self._selectedInterests = State(wrappedValue: selectedInterests) + self.interests = interests + self.onSave = onSave + } + + init( + selectedInterests: Set, + interests: [BaseInterest] + ) { + self._selectedInterests = State(wrappedValue: selectedInterests) + self.interests = interests + self.onSave = nil + } + + + var body: some View { + VStack(spacing: 0) { + title + .padding(.top, 52) + .pokitMaxWidth() + + fieldsFlow + .padding(.top, 36) + .padding(.bottom, 40) + + PokitBottomButton( + "키워드 저장", + state: selectedInterests.count == 0 ? .disable : .filled(.primary), + action: { onSave?(selectedInterests) } + ) + .pokitMaxWidth() + } + .padding(.horizontal, 20) + .background(.pokit(.bg(.base))) + .pokitPresentationCornerRadius() + .pokitPresentationBackground() + .presentationDragIndicator(.visible) + .readHeight() + .onPreferenceChange(HeightPreferenceKey.self) { height in + if let height { + self.height = height + } + } + .presentationDetents([.height(height)]) + .ignoresSafeArea(edges: [.bottom, .top]) + .onDisappear { onSave?(selectedInterests) } + } + + func onSave(_ perform: @escaping (Set) -> Void) -> Self { + RecommendKeywordBottomSheet( + selectedInterests: self.selectedInterests, + interests: self.interests, + onSave: perform + ) + } +} + +//MARK: - Configure View +extension RecommendKeywordBottomSheet { + private var title: some View { + HStack { + VStack(alignment: .leading, spacing: 12) { + Text("관심 키워드를 선택해 주세요") + .pokitFont(.title1) + .foregroundStyle(.pokit(.text(.primary))) + + Text("최대 3개의 관심 키워드를 선택하시면\n관련 링크가 추천됩니다") + .pokitFont(.title3) + .foregroundStyle(.pokit(.text(.secondary))) + } + + Spacer() + } + } + + private var fieldsFlow: some View { + PokitFlowLayout(rowSpacing: 12, colSpacing: 10) { + ForEach(interests, id: \.self) { field in + let isSelected = selectedInterests.contains(field) + let isMaxCount = selectedInterests.count >= 3 + + PokitTextChip( + field.description, + state: isSelected + ? .filled(.primary) + : isMaxCount ? .disable : .default(.primary), + size: .medium + ) { + guard !isTouchActive else { return } + + if isSelected { + selectedInterests.remove(field) + } else { + if selectedInterests.count < 3 { + selectedInterests.insert(field) + } + } + } + } + .animation(.pokitDissolve, value: selectedInterests) + } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in isTouchActive = true } + .onEnded { _ in isTouchActive = false } + ) + } +} + +@available(iOS 18.0, *) +#Preview { + @Previewable + @State + var selectedInterests = Set() + + RecommendKeywordBottomSheet( + selectedInterests: selectedInterests, + interests: [] + ) +} diff --git a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift new file mode 100644 index 00000000..509a264e --- /dev/null +++ b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift @@ -0,0 +1,316 @@ +// +// RecommendView.swift +// Feature +// +// Created by 김도형 on 1/29/25. + +import SwiftUI + +import ComposableArchitecture +import Domain +import DSKit +import NukeUI + +@ViewAction(for: RecommendFeature.self) +public struct RecommendView: View { + /// - Properties + @Perception.Bindable + public var store: StoreOf + + /// - Initializer + public init(store: StoreOf) { + self.store = store + } +} +//MARK: - View +public extension RecommendView { + var body: some View { + WithPerceptionTracking { + VStack(spacing: 0) { + interestList + .background(.pokit(.bg(.base))) + + list + } + .background(.pokit(.bg(.primary))) + .ignoresSafeArea(edges: .bottom) + .sheet(item: $store.shareContent) { content in + if let shareURL = URL(string: content.data) { + PokitShareSheet( + items: [shareURL], + completion: { send(.링크_공유_완료되었을때) } + ) + .presentationDetents([.medium, .large]) + } + } + .task { await send(.onAppear).finish() } + .sheet(isPresented: $store.showKeywordSheet) { + RecommendKeywordBottomSheet( + selectedInterests: store.selectedInterestList, + interests: store.interests + ) + .onSave { interests in + send(.키워드_선택_버튼_눌렀을때(interests)) + } + } + .sheet(item: $store.reportContent) { content in + PokitAlert( + "링크를 신고하시겠습니까?", + message: "명확한 사유가 있는 경우 신고해주시기 바랍니다. \nex)음란성/선정성 이미지, 영상, 텍스트 등의 콘텐츠\n욕설, 비속어, 모욕, 저속한 단어 등", + confirmText: "확인", + action: { send(.신고하기_확인_버튼_눌렀을때(content)) }, + cancelAction: { send(.경고시트_dismiss) } + ) + } + .sheet(isPresented: $store.showSelectSheet) { + PokitSelectSheet( + list: store.pokitList, + selectedItem: .constant(nil), + itemSelected: { item in + send(.포킷선택_항목_눌렀을때(pokit: item)) + }, + pokitAddAction: { send(.포킷_추가하기_버튼_눌렀을때) } + ) + .presentationDragIndicator(.visible) + .pokitPresentationCornerRadius() + .presentationDetents([.height(564)]) + .pokitPresentationBackground() + } + } + } +} +//MARK: - Configure View +private extension RecommendView { + var interestList: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + interestListContent(proxy) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .padding(.trailing, 40) + } + .overlay(alignment: .trailing) { + interestEditButton + } + .padding(.bottom, 4) + } + } + + var interestEditButton: some View { + PokitIconButton( + .icon(.edit), + state: .default(.secondary), + size: .small, + shape: .round, + action: { send(.관심사_편집_버튼_눌렀을때) } + ) + .padding([.leading, .vertical], 8) + .padding(.trailing, 20) + .background( + LinearGradient( + stops: [ + Gradient.Stop( + color: .pokit(.bg(.base)), + location: 0.00 + ), + Gradient.Stop( + color: .pokit(.bg(.base)).opacity(0), + location: 1.00 + ), + ], + startPoint: UnitPoint(x: 0.1, y: 0.52), + endPoint: UnitPoint(x: 0, y: 0.52) + ) + ) + } + + @ViewBuilder + func interestListContent(_ proxy: ScrollViewProxy) -> some View { + HStack(spacing: 8) { + let isAllSelected = store.selectedInterest == nil + + PokitTextButton( + "전체보기", + state: isAllSelected + ? .filled(.primary) + : .default(.secondary), + size: .small, + shape: .round, + action: { send(.전체보기_버튼_눌렀을때(proxy)) } + ) + .animation(.pokitDissolve, value: isAllSelected) + .id("전체보기") + + ForEach(store.myInterestList) { interest in + let isSelected = store.selectedInterest == interest + + PokitTextButton( + interest.description, + state: isSelected + ? .filled(.primary) + : .default(.secondary), + size: .small, + shape: .round, + action: { send(.관심사_버튼_눌렀을때(interest, proxy)) } + ) + .animation(.pokitDissolve, value: isSelected) + .id(interest.description) + } + } + .animation(.pokitDissolve, value: store.myInterestList.elements) + } + + @ViewBuilder + var list: some View { + if let recommendedList = store.recommendedList { + if recommendedList.isEmpty { + PokitCaution(type: .추천_링크없음) + } else { + listContent(recommendedList) + } + } else { + PokitLoading() + } + } + + @ViewBuilder + func listContent( + _ recommendedList: IdentifiedArrayOf + ) -> some View { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(recommendedList) { content in + recommendedCard(content) + } + + if store.hasNext { + PokitLoading() + .task { await send(.pagination).finish() } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 150) + .padding(.top, 12) + } + } + + @ViewBuilder + func recommendedCard(_ content: BaseContentItem) -> some View { + Button(action: { send(.추천_컨텐츠_눌렀을때(content.data)) }) { + recomendedCardLabel(content) + } + } + + @ViewBuilder + func recomendedCardLabel(_ content: BaseContentItem) -> some View { + VStack(alignment: .leading, spacing: 0) { + if let url = URL(string: content.thumbNail) { + recommendedImage(url: url) + } + + recommededTitle(content) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(.pokit(.bg(.base))) + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .inset(by: 0.5) + .stroke(.pokit(.border(.tertiary)), lineWidth: 1) + ) + .clipped() + .overlay(alignment: .topTrailing) { + recommendedCardButton(content) + .padding(12) + } + } + + @ViewBuilder + func recommendedCardButton(_ content: BaseContentItem) -> some View { + HStack(spacing: 6) { + PokitIconButton( + .icon(.plusR), + state: .opacity, + size: .small, + shape: .round, + action: { send(.추가하기_버튼_눌렀을때(content)) } + ) + + PokitIconButton( + .icon(.share), + state: .opacity, + size: .small, + shape: .round, + action: { send(.공유하기_버튼_눌렀을때(content)) } + ) + + PokitIconButton( + .icon(.report), + state: .opacity, + size: .small, + shape: .round, + action: { send(.신고하기_버튼_눌렀을때(content)) } + ) + } + } + + @ViewBuilder + func recommededTitle(_ content: BaseContentItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + if let keyword = content.keyword { + PokitBadge(state: .default(keyword)) + } + + PokitBadge(state: .default(content.domain)) + + Spacer() + } + + Text(content.title) + .foregroundStyle(.pokit(.text(.primary))) + .pokitFont(.b3(.b)) + .multilineTextAlignment(.leading) + .lineLimit(2) + } + } + + @MainActor + @ViewBuilder + func recommendedImage(url: URL) -> some View { + LazyImage(url: url) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else if phase.error != nil { + imagePlaceholder + } else { + imagePlaceholder + } + } + .frame(height: 141) + } + + var imagePlaceholder: some View { + ZStack { + Color.pokit(.bg(.disable)) + + PokitSpinner() + .foregroundStyle(.pink) + .frame(width: 48, height: 48) + } + } +} +//MARK: - Preview +#Preview { + RecommendView( + store: Store( + initialState: .init(), + reducer: { RecommendFeature() } + ) + ) +} + + diff --git a/Projects/Feature/FeatureRecommendDemo/Resources/LaunchScreen.storyboard b/Projects/Feature/FeatureRecommendDemo/Resources/LaunchScreen.storyboard new file mode 100644 index 00000000..f1721f80 --- /dev/null +++ b/Projects/Feature/FeatureRecommendDemo/Resources/LaunchScreen.storyboard @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift b/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift new file mode 100644 index 00000000..525a502e --- /dev/null +++ b/Projects/Feature/FeatureRecommendDemo/Sources/FeatureRecommendDemoApp.swift @@ -0,0 +1,31 @@ +// +// App.stencil.swift +// ProjectDescriptionHelpers +// +// Created by 김도형 on 6/16/24. +// + +import SwiftUI + +import FeatureRecommend +import FeatureIntro +import CoreKit + +@main +struct FeatureRecommendDemoApp: App { + var body: some Scene { + WindowGroup { + // TODO: 루트 뷰 추가 + + DemoView(store: .init( + initialState: .init(), + reducer: { DemoFeature() } + )) { + RecommendView(store: .init( + initialState: .init(), + reducer: { RecommendFeature()._printChanges() } + )) + } + } + } +} diff --git a/Projects/Feature/FeatureRecommendTests/Resources/info.plist b/Projects/Feature/FeatureRecommendTests/Resources/info.plist new file mode 100644 index 00000000..b31ce7b0 --- /dev/null +++ b/Projects/Feature/FeatureRecommendTests/Resources/info.plist @@ -0,0 +1,8 @@ + + + + + ENABLE_TESTING_SEARCH_PATHS + YES + + diff --git a/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift b/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift new file mode 100644 index 00000000..a54bfab4 --- /dev/null +++ b/Projects/Feature/FeatureRecommendTests/Sources/FeatureRecommendTests.swift @@ -0,0 +1,10 @@ +import ComposableArchitecture +import XCTest + +@testable import FeatureRecommend + +final class FeatureRecommendTests: XCTestCase { + func test() { + + } +} diff --git a/Projects/Feature/FeatureRemind/Resources/.gitkeep b/Projects/Feature/FeatureRemind/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureRemind/Resources/Resource.swift b/Projects/Feature/FeatureRemind/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureRemind/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift b/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift deleted file mode 100644 index 4638db6b..00000000 --- a/Projects/Feature/FeatureRemind/Sources/Remind/RemindFeature.swift +++ /dev/null @@ -1,309 +0,0 @@ -// -// RemindFeature.swift -// Feature -// -// Created by 김도형 on 7/12/24. - -import SwiftUI - -import ComposableArchitecture -import Domain -import CoreKit -import Util -import DSKit - -@Reducer -public struct RemindFeature { - /// - Dependency - @Dependency(\.dismiss) - private var dismiss - @Dependency(\.openURL) - private var openURL - @Dependency(RemindClient.self) - private var remindClient - @Dependency(ContentClient.self) - private var contentClient - @Dependency(SwiftSoupClient.self) - private var swiftSoupClient - /// - State - @ObservableState - public struct State: Equatable { - public init() {} - - fileprivate var domain = Remind() - var recommendedContents: IdentifiedArrayOf? { - guard let recommendedList = domain.recommendedList else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - recommendedList.forEach { identifiedArray.append($0) } - return identifiedArray - } - var unreadContents: IdentifiedArrayOf? { - guard let unreadList = domain.unreadList.data else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - unreadList.forEach { identifiedArray.append($0) } - return identifiedArray - } - var favoriteContents: IdentifiedArrayOf? { - guard let favoriteList = domain.favoriteList.data else { - return nil - } - var identifiedArray = IdentifiedArrayOf() - favoriteList.forEach { identifiedArray.append($0) } - return identifiedArray - } - } - /// - Action - public enum Action: FeatureAction, ViewAction { - case view(View) - case inner(InnerAction) - case async(AsyncAction) - case scope(ScopeAction) - case delegate(DelegateAction) - - public enum View: Equatable, BindableAction { - case binding(BindingAction) - - /// - Button Tapped - case 알림_버튼_눌렀을때 - case 검색_버튼_눌렀을때 - case 컨텐츠_항목_눌렀을때(content: BaseContentItem) - case 컨텐츠_항목_케밥_버튼_눌렀을때(content: BaseContentItem) - case 안읽음_목록_버튼_눌렀을때 - case 즐겨찾기_목록_버튼_눌렀을때 - - case 뷰가_나타났을때 - case 즐겨찾기_항목_이미지_조회(contentId: Int) - case 읽지않음_항목_이미지_조회(contentId: Int) - case 리마인드_항목_이미지오류_나타났을때(contentId: Int) - } - public enum InnerAction: Equatable { - case 오늘의_리마인드_조회_API_반영(contents: [BaseContentItem]) - case 읽지않음_컨텐츠_조회_API_반영(contentList: BaseContentListInquiry) - case 즐겨찾기_링크모음_조회_API_반영(contentList: BaseContentListInquiry) - case 즐겨찾기_이미지_조회_수행_반영(imageURL: String, index: Int) - case 읽지않음_이미지_조회_수행_반영(imageURL: String, index: Int) - case 리마인드_이미지_조회_수행_반영(imageURL: String, index: Int) - - } - public enum AsyncAction: Equatable { - case 오늘의_리마인드_조회_API - case 읽지않음_컨텐츠_조회_API - case 즐겨찾기_링크모음_조회_API - case 썸네일_수정_API(imageURL: String, contentId: Int) - case 즐겨찾기_이미지_조회_수행(contentId: Int) - case 읽지않음_이미지_조회_수행(contentId: Int) - case 리마인드_이미지_조회_수행(contentId: Int) - } - public enum ScopeAction: Equatable { case 없음 } - public enum DelegateAction: Equatable { - case 링크상세(content: BaseContentItem) - case alertButtonTapped - case searchButtonTapped - case 링크수정(id: Int) - case 링크목록_안읽음 - case 링크목록_즐겨찾기 - case 컨텐츠_상세보기_delegate_위임 - } - } - /// initiallizer - public init() {} - /// - Reducer Core - private func core(into state: inout State, action: Action) -> Effect { - switch action { - /// - View - case .view(let viewAction): - return handleViewAction(viewAction, state: &state) - /// - Inner - case .inner(let innerAction): - return handleInnerAction(innerAction, state: &state) - /// - Async - case .async(let asyncAction): - return handleAsyncAction(asyncAction, state: &state) - /// - Scope - case .scope(let scopeAction): - return handleScopeAction(scopeAction, state: &state) - /// - Delegate - case .delegate(let delegateAction): - return handleDelegateAction(delegateAction, state: &state) - } - } - /// - Reducer body - public var body: some ReducerOf { - BindingReducer(action: \.view) - Reduce(self.core) - } -} -//MARK: - FeatureAction Effect -private extension RemindFeature { - /// - View Effect - func handleViewAction(_ action: Action.View, state: inout State) -> Effect { - switch action { - case .binding: - return .none - case .알림_버튼_눌렀을때: - return .send(.delegate(.alertButtonTapped)) - case .검색_버튼_눌렀을때: - return .send(.delegate(.searchButtonTapped)) - case .즐겨찾기_목록_버튼_눌렀을때: - return .send(.delegate(.링크목록_즐겨찾기)) - case .안읽음_목록_버튼_눌렀을때: - return .send(.delegate(.링크목록_안읽음)) - case .컨텐츠_항목_케밥_버튼_눌렀을때(let content): - return .send(.delegate(.링크상세(content: content))) - case .컨텐츠_항목_눌렀을때(let content): - guard let url = URL(string: content.data) else { return .none } - return .run { _ in await openURL(url) } - case .뷰가_나타났을때: - return allContentFetch(animation: .pokitDissolve) - case let .즐겨찾기_항목_이미지_조회(contentId): - return .send(.async(.즐겨찾기_이미지_조회_수행(contentId: contentId))) - case let .읽지않음_항목_이미지_조회(contentId): - return .send(.async(.읽지않음_이미지_조회_수행(contentId: contentId))) - case let .리마인드_항목_이미지오류_나타났을때(contentId): - return .send(.async(.리마인드_이미지_조회_수행(contentId: contentId))) - } - } - /// - Inner Effect - func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { - switch action { - case .오늘의_리마인드_조회_API_반영(contents: let contents): - state.domain.recommendedList = contents - return .none - case .읽지않음_컨텐츠_조회_API_반영(contentList: let contentList): - state.domain.unreadList = contentList - return .none - case .즐겨찾기_링크모음_조회_API_반영(contentList: let contentList): - state.domain.favoriteList = contentList - return .none - case let .즐겨찾기_이미지_조회_수행_반영(imageURL, index): - var content = state.domain.favoriteList.data?.remove(at: index) - content?.thumbNail = imageURL - guard let content else { return .none } - state.domain.favoriteList.data?.insert(content, at: index) - return .send(.async(.썸네일_수정_API(imageURL: imageURL, contentId: content.id))) - case let .읽지않음_이미지_조회_수행_반영(imageURL, index): - var content = state.domain.unreadList.data?.remove(at: index) - content?.thumbNail = imageURL - guard let content else { return .none } - state.domain.unreadList.data?.insert(content, at: index) - return .send(.async(.썸네일_수정_API(imageURL: imageURL, contentId: content.id))) - case let .리마인드_이미지_조회_수행_반영(imageURL, index): - var content = state.domain.recommendedList?.remove(at: index) - content?.thumbNail = imageURL - guard let content else { return .none } - state.domain.recommendedList?.insert(content, at: index) - return .send(.async(.썸네일_수정_API(imageURL: imageURL, contentId: content.id))) - } - } - /// - Async Effect - func handleAsyncAction(_ action: Action.AsyncAction, state: inout State) -> Effect { - switch action { - case .오늘의_리마인드_조회_API: - return .run { send in - let contents = try await remindClient.오늘의_리마인드_조회().map { $0.toDomain() } - await send(.inner(.오늘의_리마인드_조회_API_반영(contents: contents)), animation: .pokitDissolve) - } - case .읽지않음_컨텐츠_조회_API: - return .run { [pageable = state.domain.unreadListPageable] send in - let contentList = try await remindClient.읽지않음_컨텐츠_조회( - BasePageableRequest( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ) - ).toDomain() - await send(.inner(.읽지않음_컨텐츠_조회_API_반영(contentList: contentList)), animation: .pokitDissolve) - } - case .즐겨찾기_링크모음_조회_API: - return .run { [pageable = state.domain.favoriteListPageable] send in - let contentList = try await remindClient.즐겨찾기_링크모음_조회( - BasePageableRequest( - page: pageable.page, - size: pageable.size, - sort: pageable.sort - ) - ).toDomain() - await send(.inner(.즐겨찾기_링크모음_조회_API_반영(contentList: contentList)), animation: .pokitDissolve) - } - case let .즐겨찾기_이미지_조회_수행(contentId): - return .run { [favoriteContents = state.favoriteContents] send in - guard - let index = favoriteContents?.index(id: contentId), - let content = favoriteContents?[index], - let url = URL(string: content.data) - else { return } - - let imageURL = try await swiftSoupClient.parseOGImageURL(url) - guard let imageURL else { return } - - await send(.inner(.즐겨찾기_이미지_조회_수행_반영( - imageURL: imageURL, - index: index - ))) - } - case let .읽지않음_이미지_조회_수행(contentId): - return .run { [unreadContents = state.unreadContents] send in - guard - let index = unreadContents?.index(id: contentId), - let content = unreadContents?[index], - let url = URL(string: content.data) - else { return } - let imageURL = try await swiftSoupClient.parseOGImageURL(url) - guard let imageURL else { return } - - await send(.inner(.읽지않음_이미지_조회_수행_반영( - imageURL: imageURL, - index: index - ))) - } - case let .리마인드_이미지_조회_수행(contentId): - return .run { [recommendedContents = state.recommendedContents] send in - guard - let index = recommendedContents?.index(id: contentId), - let content = recommendedContents?[index], - let url = URL(string: content.data) - else { return } - let imageURL = try await swiftSoupClient.parseOGImageURL(url) - guard let imageURL else { return } - - await send(.inner(.리마인드_이미지_조회_수행_반영( - imageURL: imageURL, - index: index - ))) - } - case let .썸네일_수정_API(imageURL, contentId): - return .run { send in - let request = ThumbnailRequest(thumbnail: imageURL) - - try await contentClient.썸네일_수정( - contentId: "\(contentId)", - model: request - ) - } - } - } - /// - Scope Effect - func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { - return .none - } - /// - Delegate Effect - func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { - switch action { - case .컨텐츠_상세보기_delegate_위임: - return allContentFetch() - default: return .none - } - } - - func allContentFetch(animation: Animation? = nil) -> Effect { - return .run { send in - await send(.async(.오늘의_리마인드_조회_API), animation: animation) - await send(.async(.읽지않음_컨텐츠_조회_API), animation: animation) - await send(.async(.즐겨찾기_링크모음_조회_API), animation: animation) - } - } -} diff --git a/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift b/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift deleted file mode 100644 index f6400565..00000000 --- a/Projects/Feature/FeatureRemind/Sources/Remind/RemindView.swift +++ /dev/null @@ -1,301 +0,0 @@ -// -// RemindView.swift -// Feature -// -// Created by 김도형 on 7/12/24. - -import SwiftUI - -import ComposableArchitecture -import Domain -import DSKit -import NukeUI -import Util - -@ViewAction(for: RemindFeature.self) -public struct RemindView: View { - /// - Properties - @Perception.Bindable - public var store: StoreOf - private let formatter = DateFormat.yearMonthDate.formatter - /// - Initializer - public init(store: StoreOf) { - self.store = store - } -} -//MARK: - View -public extension RemindView { - var body: some View { - WithPerceptionTracking { - contents - .background(.pokit(.bg(.base))) - .ignoresSafeArea(edges: .bottom) - .navigationBarBackButtonHidden(true) - .task { await send(.뷰가_나타났을때, animation: .pokitDissolve).finish() } - } - } -} -//MARK: - Configure View -extension RemindView { - private var contents: some View { - Group { - if let recommendedContents = store.recommendedContents, - let unreadContents = store.unreadContents, - let favoriteContents = store.favoriteContents { - if recommendedContents.isEmpty && - unreadContents.isEmpty && - favoriteContents.isEmpty { - VStack { - PokitCaution(type: .링크부족) - .padding(.top, 100) - - Spacer() - } - } else { - ScrollView { - VStack(spacing: 32) { - recommededContentList(recommendedContents) - - Group { - unreadContentList(unreadContents) - - favoriteContentList(favoriteContents) - } - .padding(.horizontal, 20) - - Spacer() - } - .padding(.bottom, 150) - } - } - } else { - PokitLoading() - } - } - } - - @ViewBuilder - private func recommededContentList( - _ recommendedContents: IdentifiedArrayOf - ) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text("오늘 이 링크는 어때요?") - .pokitFont(.title2) - .foregroundStyle(.pokit(.text(.primary))) - .padding(.horizontal, 20) - - if recommendedContents.isEmpty { - PokitCaution(type: .링크부족) - .padding(.top, 24) - .padding(.bottom, 32) - } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(recommendedContents, id: \.id) { content in - recommendedContentCell(content: content) - } - } - .padding(.horizontal, 20) - } - } - } - } - - @ViewBuilder - private func recommendedContentCell(content: BaseContentItem) -> some View { - Button(action: { send(.컨텐츠_항목_눌렀을때(content: content)) }) { - recommendedContentCellLabel(content: content) - } - } - - @ViewBuilder - private func recommendedContentCellLabel(content: BaseContentItem) -> some View { - ZStack(alignment: .bottom) { - LinearGradient( - stops: [ - Gradient.Stop( - color: .black.opacity(0), - location: 0.00 - ), - Gradient.Stop( - color: Color(red: 0.02, green: 0.02, blue: 0.02).opacity(0.49), - location: 1.00 - ), - ], - startPoint: .top, - endPoint: .bottom - ) - - VStack(alignment: .leading, spacing: 0) { - PokitBadge(state: .small(content.categoryName)) - - HStack(spacing: 4) { - Text(content.title) - .pokitFont(.b2(.b)) - .foregroundStyle(.pokit(.text(.inverseWh))) - .multilineTextAlignment(.leading) - .lineLimit(2) - - Spacer() - - kebabButton { - send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) - } - .foregroundStyle(.pokit(.icon(.inverseWh))) - .zIndex(1) - - } - .padding(.top, 4) - - Text("\(content.createdAt) • \(content.domain)") - .pokitFont(.detail2) - .foregroundStyle(.pokit(.text(.tertiary))) - .padding(.top, 8) - } - .padding(12) - } - .frame(width: 216, height: 194) - .background { - if let url = URL(string: content.thumbNail) { - recommendedContentCellImage(url: url, contentId: content.id) - } else { - imagePlaceholder - } - } - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .clipped() - } - - @MainActor - private func recommendedContentCellImage(url: URL, contentId: Int) -> some View { - LazyImage(url: url) { phase in - if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - } else if phase.error != nil { - imagePlaceholder - .task { await send(.리마인드_항목_이미지오류_나타났을때(contentId: contentId)).finish() } - } else { - imagePlaceholder - } - } - } - - private var imagePlaceholder: some View { - ZStack { - Color.pokit(.bg(.disable)) - - PokitSpinner() - .foregroundStyle(.pink) - .frame(width: 48, height: 48) - } - } - - @ViewBuilder - private func kebabButton(action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(.icon(.kebab)) - .resizable() - .frame(width: 24, height: 24) - } - } - - @ViewBuilder - private func listNavigationLink( - _ title: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack { - Text(title) - .pokitFont(.title2) - - Spacer() - - Image(.icon(.arrowRight)) - .resizable() - .frame(width: 24, height: 24) - } - .foregroundStyle(.pokit(.icon(.primary))) - } - } - - @ViewBuilder - private func unreadContentList( - _ unreadContents: IdentifiedArrayOf - ) -> some View { - Group { - if !unreadContents.isEmpty { - VStack(spacing: 0) { - VStack(spacing: 0) { - listNavigationLink("한번도 읽지 않았어요") { - send(.안읽음_목록_버튼_눌렀을때) - } - .padding(.bottom, 16) - } - - ForEach(unreadContents, id: \.id) { content in - let isFirst = content.id == unreadContents.first?.id - let isLast = content.id == unreadContents.last?.id - - PokitLinkCard( - link: content, - state: isFirst - ? .top - : isLast ? .bottom : .middle, - action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, - kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) }, - fetchMetaData: { send(.읽지않음_항목_이미지_조회(contentId: content.id)) } - ) - } - } - } - } - } - - @ViewBuilder - private func favoriteContentList( - _ favoriteContents: IdentifiedArrayOf - ) -> some View { - VStack(spacing: 0) { - listNavigationLink("즐겨찾기 링크만 모았어요") { - send(.즐겨찾기_목록_버튼_눌렀을때) - } - .padding(.bottom, 16) - - if favoriteContents.isEmpty { - PokitCaution(type: .즐겨찾기_링크없음) - .padding(.top, 16) - } else { - ForEach(favoriteContents, id: \.id) { content in - let isFirst = content.id == favoriteContents.first?.id - let isLast = content.id == favoriteContents.last?.id - - PokitLinkCard( - link: content, - state: isFirst - ? .top - : isLast ? .bottom : .middle, - action: { send(.컨텐츠_항목_눌렀을때(content: content)) }, - kebabAction: { send(.컨텐츠_항목_케밥_버튼_눌렀을때(content: content)) }, - fetchMetaData: { send(.즐겨찾기_항목_이미지_조회(contentId: content.id)) } - ) - } - } - } - } -} -//MARK: - Preview -#Preview { - NavigationStack { - RemindView( - store: Store( - initialState: .init(), - reducer: { RemindFeature() } - ) - ) - } -} - - diff --git a/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift b/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift deleted file mode 100644 index daa6c95d..00000000 --- a/Projects/Feature/FeatureRemindDemo/Sources/FeatureRemindDemoApp.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// App.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import SwiftUI -import ComposableArchitecture - -import FeatureRemind - -@main -struct FeatureRemindDemoApp: App { - var body: some Scene { - WindowGroup { - // TODO: 루트 뷰 추가 - NavigationStack { - RemindView( - store: .init( - initialState: .init(), - reducer: { RemindFeature() } - ) - ) - } - } - } -} - -#Preview { - RemindView( - store: .init( - initialState: .init(), - reducer: { RemindFeature() } - ) - ) -} diff --git a/Projects/Feature/FeatureSetting/Resources/.gitkeep b/Projects/Feature/FeatureSetting/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/FeatureSetting/Resources/Resource.swift b/Projects/Feature/FeatureSetting/Resources/Resource.swift deleted file mode 100644 index 43790c92..00000000 --- a/Projects/Feature/FeatureSetting/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Dummy.stencil.swift -// ProjectDescriptionHelpers -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift index 1c991664..130d4872 100644 --- a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchFeature.swift @@ -96,8 +96,8 @@ public struct PokitSearchFeature { case 카테고리_버튼_눌렀을때 case 모아보기_버튼_눌렀을때 case 기간_버튼_눌렀을때 - case 카테고리_태그_눌렀을때(category: BaseCategoryItem) - case 즐겨찾기_태그_눌렀을때 + case 카테고리_태그_눌렀을때 + case 컨텐츠_타입_태그_눌렀을때 case 안읽음_태그_눌렀을때 case 전체_삭제_버튼_눌렀을때 case 정렬_버튼_눌렀을때 @@ -236,17 +236,7 @@ private extension PokitSearchFeature { return .send(.inner(.filterBottomSheet(filterType: .contentType))) case .기간_버튼_눌렀을때: - guard - state.domain.condition.startDate != nil && - state.domain.condition.endDate != nil - /// - 선택된 기간이 없을 경우 - else { return .send(.inner(.filterBottomSheet(filterType: .date))) } - state.domain.condition.startDate = nil - state.domain.condition.endDate = nil - return .run { send in - await send(.inner(.기간_업데이트(startDate: nil, endDate: nil))) - await send(.inner(.페이징_초기화), animation: .pokitDissolve) - } + return .send(.inner(.filterBottomSheet(filterType: .date))) case .카테고리_버튼_눌렀을때: return .send(.inner(.filterBottomSheet(filterType: .pokit))) @@ -287,20 +277,14 @@ private extension PokitSearchFeature { return .merge(effectBox) - case let .카테고리_태그_눌렀을때(category): - state.categoryFilter.remove(category) - return .run { send in - await send(.inner(.카테고리_ID_목록_업데이트)) - await send(.inner(.페이징_초기화)) - } + case .카테고리_태그_눌렀을때: + return .send(.inner(.filterBottomSheet(filterType: .pokit))) - case .즐겨찾기_태그_눌렀을때: - state.domain.condition.favorites = false - return .send(.inner(.페이징_초기화)) + case .컨텐츠_타입_태그_눌렀을때: + return .send(.inner(.filterBottomSheet(filterType: .contentType))) case .안읽음_태그_눌렀을때: - state.domain.condition.isRead = false - return .send(.inner(.페이징_초기화)) + return .send(.inner(.filterBottomSheet(filterType: .contentType))) case .로딩중일때: return .send(.async(.컨텐츠_검색_페이징_API)) diff --git a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift index 2984dd3f..6a819894 100644 --- a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift +++ b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift @@ -16,7 +16,7 @@ public struct PokitSearchView: View { @Perception.Bindable public var store: StoreOf @FocusState - private var focused: Bool + private var focused: Bool /// - Initializer public init(store: StoreOf) { @@ -43,6 +43,7 @@ public extension PokitSearchView { .background(.pokit(.bg(.base))) .ignoresSafeArea(edges: .bottom) .navigationBarBackButtonHidden(true) + .dismissKeyboard(focused: $focused) .sheet( item: $store.scope( state: \.filterBottomSheet, @@ -71,7 +72,7 @@ private extension PokitSearchView { action: store.isSearching ? { send(.검색_버튼_눌렀을때) } : nil ), shape: .round, - state: .constant(.default), + state: .constant(focused ? .active : .default), placeholder: "제목, 메모를 검색해보세요.", focusState: $focused, equals: true, @@ -110,28 +111,31 @@ private extension PokitSearchView { .padding(.horizontal, 20) } + @ViewBuilder var recentSearch: some View { - VStack(spacing: 20) { - recentSearchTitle - - if store.isSearching { - filterToolbar - } else if store.isAutoSaveSearch { - if store.recentSearchTexts.isEmpty { - Text("검색 내역이 없습니다.") + if store.isSearching { + filterToolbar + } else { + VStack(spacing: 20) { + recentSearchTitle + + if store.isAutoSaveSearch { + if store.recentSearchTexts.isEmpty { + Text("검색 내역이 없습니다.") + .pokitFont(.b3(.r)) + .foregroundStyle(.pokit(.text(.tertiary))) + .pokitBlurReplaceTransition(.pokitDissolve) + .padding(.vertical, 5) + } else { + recentSearchList + } + } else { + Text("최근 검색 저장 기능이 꺼져있습니다.") .pokitFont(.b3(.r)) .foregroundStyle(.pokit(.text(.tertiary))) .pokitBlurReplaceTransition(.pokitDissolve) .padding(.vertical, 5) - } else { - recentSearchList } - } else { - Text("최근 검색 저장 기능이 꺼져있습니다.") - .pokitFont(.b3(.r)) - .foregroundStyle(.pokit(.text(.tertiary))) - .pokitBlurReplaceTransition(.pokitDissolve) - .padding(.vertical, 5) } } } @@ -194,68 +198,64 @@ private extension PokitSearchView { ) } + @ViewBuilder var categoryFilterButton: some View { - Group { - if store.categoryFilter.isEmpty { - PokitIconRChip( - "포킷", - icon: .icon(.arrowDown), - state: .default(.primary), - size: .small, - action: { send(.카테고리_버튼_눌렀을때) } - ) - } else { - ForEach(store.categoryFilter) { category in - PokitIconRChip( - category.categoryName, - state: .stroke(.primary), - size: .small, - action: { send(.카테고리_태그_눌렀을때(category: category), animation: .pokitSpring) } - ) - .pokitBlurReplaceTransition(.pokitDissolve) - } - } + if store.categoryFilter.isEmpty { + PokitIconRChip( + "포킷", + icon: .icon(.arrowDown), + state: .default(.primary), + size: .small, + action: { send(.카테고리_버튼_눌렀을때) } + ) + } else { + let firstName = store.categoryFilter.first?.categoryName ?? "" + let text = store.categoryFilter.count == 1 + ? firstName + : firstName + " 외 \(store.categoryFilter.count - 1)" + + PokitIconRChip( + text, + icon: .icon(.arrowDown), + state: .stroke(.primary), + size: .small, + action: { send(.카테고리_태그_눌렀을때, animation: .pokitSpring) } + ) + .pokitBlurReplaceTransition(.pokitDissolve) } } + @ViewBuilder var contentTypeFilterButton: some View { - Group { - if !store.favoriteFilter && !store.unreadFilter { - PokitIconRChip( - "모아보기", - icon: .icon(.arrowDown), - state: .default(.primary), - size: .small, - action: { send(.모아보기_버튼_눌렀을때) } - ) - } else { - if store.favoriteFilter { - PokitIconRChip( - "즐겨찾기", - state: .stroke(.primary), - size: .small, - action: { send(.즐겨찾기_태그_눌렀을때, animation: .pokitSpring) } - ) - .pokitBlurReplaceTransition(.pokitDissolve) - } - - if store.unreadFilter { - PokitIconRChip( - "안읽음", - state: .stroke(.primary), - size: .small, - action: { send(.안읽음_태그_눌렀을때, animation: .pokitSpring) } - ) - .pokitBlurReplaceTransition(.pokitDissolve) - } - } + if !store.favoriteFilter && !store.unreadFilter { + PokitIconRChip( + "모아보기", + icon: .icon(.arrowDown), + state: .default(.primary), + size: .small, + action: { send(.모아보기_버튼_눌렀을때) } + ) + } else { + let contentTypes = [ + store.favoriteFilter ? "즐겨찾기" : nil, + store.unreadFilter ? "안읽음" : nil + ].compactMap(\.self).joined(separator: ", ") + + PokitIconRChip( + contentTypes, + icon: .icon(.arrowDown), + state: .stroke(.primary), + size: .small, + action: { send(.컨텐츠_타입_태그_눌렀을때, animation: .pokitSpring) } + ) + .pokitBlurReplaceTransition(.pokitDissolve) } } var dateFilterButton: some View { PokitIconRChip( store.dateFilterText, - icon: store.dateFilterText == "기간" ? .icon(.arrowDown) : .icon(.x), + icon: .icon(.arrowDown), state: store.dateFilterText == "기간" ? .default(.primary) : .stroke(.primary), size: .small, action: { send(.기간_버튼_눌렀을때, animation: .pokitSpring) } @@ -265,14 +265,6 @@ private extension PokitSearchView { var resultList: some View { VStack(alignment: .leading, spacing: 20) { - PokitIconLTextLink( - store.isResultAscending ? "오래된순" : "최신순", - icon: .icon(.align), - action: { send(.정렬_버튼_눌렀을때) } - ) - .contentTransition(.numericText()) - .padding(.horizontal, 20) - if !store.isLoading { ScrollView { LazyVStack(spacing: 0) { diff --git a/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomFeature.swift b/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomFeature.swift index 95a2d5c3..ee93233b 100644 --- a/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Search/Sheet/FilterBottomFeature.swift @@ -229,6 +229,7 @@ private extension FilterBottomFeature { size: pageable.size, sort: pageable.sort ), + true, true ).toDomain() await send(.inner(.카테고리_목록_조회_API_반영(categoryList: categoryList)), animation: .pokitDissolve) diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingFeature.swift b/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingFeature.swift index 7d339461..6f6317e0 100644 --- a/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingFeature.swift @@ -5,21 +5,29 @@ // Created by 김민호 on 7/22/24. import ComposableArchitecture -import Domain + import CoreKit +import Domain import DSKit import Util @Reducer public struct NickNameSettingFeature { /// - Dependency - @Dependency(\.dismiss) var dismiss - @Dependency(\.mainQueue) var mainQueue - @Dependency(UserClient.self) var userClient + @Dependency(\.dismiss) + var dismiss + @Dependency(\.mainQueue) + var mainQueue + @Dependency(UserClient.self) + var userClient + @Dependency(CategoryClient.self) + var categoryClient + @Dependency(KeyboardClient.self) + var keyboardClient /// - State @ObservableState public struct State: Equatable { - fileprivate var domain = NicknameSetting() + fileprivate var domain: NicknameSetting var text: String { get { self.domain.nickname } set { self.domain.nickname = newValue } @@ -28,10 +36,33 @@ public struct NickNameSettingFeature { get { domain.user } } + var selectedProfile: BaseProfile? { + get { domain.selectedProfile } + set { domain.selectedProfile = newValue } + } + + var profileImages: [BaseProfile] { + get { domain.imageList } + } + var textfieldState: PokitInputStyle.State = .default var buttonState: PokitButtonStyle.State = .disable + var isProfileSheetPresented: Bool = false + var isKeyboardVisible: Bool = false - public init() {} + public init(user: BaseUser?) { + if let user, + let profile = user.profile { + self.domain = .init( + selectedProfile: BaseProfile( + id: profile.id, + imageURL: profile.imageURL + ) + ) + } else { + self.domain = .init(selectedProfile: nil) + } + } } /// - Action @@ -46,23 +77,31 @@ public struct NickNameSettingFeature { public enum View: BindableAction, Equatable { case binding(BindingAction) case dismiss + case 저장_버튼_눌렀을때 case 뷰가_나타났을때 case 닉네임지우기_버튼_눌렀을때 + case 프로필_설정_버튼_눌렀을때 } public enum InnerAction: Equatable { case 닉네임_텍스트_변경되었을때 case 닉네임_중복_확인_API_반영(Bool) case 닉네임_조회_API_반영(BaseUser) + case 프로필_목록_조회_API_반영(images: [BaseProfile]) + case 키보드_감지_반영(Bool) } public enum AsyncAction: Equatable { case 닉네임_중복_확인_API case 닉네임_조회_API + case 프로필_목록_조회_API + case 키보드_감지 } - public enum ScopeAction: Equatable { case 없음 } + public enum ScopeAction { + case profile(PokitProfileBottomSheet.Delegate) + } public enum DelegateAction: Equatable { case 없음 } } @@ -124,16 +163,31 @@ private extension NickNameSettingFeature { return .run { _ in await dismiss() } case .저장_버튼_눌렀을때: - return .run { [nickName = state.text] send in - let request = NicknameEditRequest(nickname: nickName) - let _ = try await userClient.닉네임_수정(request) + return .run { [nickName = state.text, selectedImage = state.selectedProfile] send in + if let selectedImage { + let request = ProfileEditRequest(profileImageId: selectedImage.id, nickname: nickName) + let _ = try await userClient.프로필_수정(model: request) + } else { + let request = ProfileEditRequest(profileImageId: nil, nickname: nickName) + let _ = try await userClient.프로필_수정(model: request) + } await dismiss() } case .뷰가_나타났을때: - return .send(.async(.닉네임_조회_API)) + return .merge( + .send(.async(.프로필_목록_조회_API)), + .send(.async(.닉네임_조회_API)), + .send(.async(.키보드_감지)) + ) + case .닉네임지우기_버튼_눌렀을때: state.domain.nickname = "" + state.buttonState = .disable + return .none + + case .프로필_설정_버튼_눌렀을때: + state.isProfileSheetPresented.toggle() return .none } } @@ -142,26 +196,49 @@ private extension NickNameSettingFeature { func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { case .닉네임_텍스트_변경되었을때: - /// [1]. 닉네임 텍스트필드가 비어있을 때 + /// [1]. 닉네임 변경 X, 현재 프로필이 nil이면서 프로필을 선택했을 때 -> 버튼 활성화 + if let currentNickName = state.user?.nickname, + let _ = state.selectedProfile { + if currentNickName == state.text && + state.user?.profile == nil { + state.buttonState = .filled(.primary) + return .none + } + } + /// [2]. 닉네임 변경 X,, 프로필만 변경했을 때 -> 버튼 활성화 + if let currentNickName = state.user?.nickname, + let currentProfile = state.user?.profile, + let selectedProfile = state.selectedProfile { + if currentNickName == state.text && currentProfile != selectedProfile { + state.buttonState = .filled(.primary) + return .none + } + } + /// [3]. 닉네임 텍스트필드가 비어있을 때 if state.text.isEmpty { state.buttonState = .disable return .none } - /// [2]. 닉네임이 10자를 넘을 때 + /// [4]. 닉네임이 10자를 넘을 때 if state.text.count > 10 { state.buttonState = .disable state.textfieldState = .error(message: "최대 10자까지 입력 가능합니다.") return .none } - /// [3]. 닉네임에 특수문자가 포함되어 있을 때 + /// [5]. 닉네임에 특수문자가 포함되어 있을 때 if !state.text.isNickNameValid { state.buttonState = .disable state.textfieldState = .error(message: "한글, 영어, 숫자만 입력이 가능합니다.") return .none - } else { - /// [4]. 정상 케이스일 때 - return .run { send in await send(.async(.닉네임_중복_확인_API)) } } + /// [6]. 현재 닉네임 == 입력한 닉네임 + if state.user?.nickname == state.text { + state.textfieldState = .active + state.buttonState = .disable + return .none + } + /// [7]. 정상 케이스일 때 + return .run { send in await send(.async(.닉네임_중복_확인_API)) } case let .닉네임_중복_확인_API_반영(isDuplicate): state.textfieldState = isDuplicate @@ -176,6 +253,19 @@ private extension NickNameSettingFeature { case let .닉네임_조회_API_반영(user): state.domain.user = user state.domain.nickname = user.nickname + if let profile = user.profile { + state.selectedProfile = BaseProfile(id: profile.id, imageURL: profile.imageURL) + } else { + state.selectedProfile = nil + } + return .none + + case let .프로필_목록_조회_API_반영(images): + state.domain.imageList = images + return .none + + case let .키보드_감지_반영(response): + state.isKeyboardVisible = response return .none } } @@ -194,12 +284,32 @@ private extension NickNameSettingFeature { let user = try await userClient.닉네임_조회().toDomain() await send(.inner(.닉네임_조회_API_반영(user)), animation: .easeInOut) } + + case .프로필_목록_조회_API: + return .run { send in + let response = try await userClient.프로필_이미지_목록_조회() + let images = response.map { $0.toDomain() } + await send(.inner(.프로필_목록_조회_API_반영(images: images))) + } + + case .키보드_감지: + return .run { send in + for await detect in await keyboardClient.isVisible() { + await send(.inner(.키보드_감지_반영(detect)), animation: .pokitSpring) + } + } } } /// - Scope Effect func handleScopeAction(_ action: Action.ScopeAction, state: inout State) -> Effect { - return .none + switch action { + case .profile(.이미지_선택했을때(let imageInfo)): + state.isProfileSheetPresented = false + state.selectedProfile = imageInfo + + return .send(.inner(.닉네임_텍스트_변경되었을때)) + } } /// - Delegate Effect diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingView.swift b/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingView.swift index e4b7c249..a953a43e 100644 --- a/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingView.swift +++ b/Projects/Feature/FeatureSetting/Sources/Setting/NickNameSetting/NickNameSettingView.swift @@ -7,8 +7,11 @@ import SwiftUI import ComposableArchitecture +import CoreKit +import Domain import DSKit import Util +import NukeUI @ViewAction(for: NickNameSettingFeature.self) public struct NickNameSettingView: View { @@ -30,19 +33,8 @@ public extension NickNameSettingView { if store.user == nil { PokitLoading() } else { - PokitTextInput( - text: $store.text, - type: store.text.isEmpty ? .text : .iconR( - icon: .icon(.x), - action: { send(.닉네임지우기_버튼_눌렀을때) } - ), - shape: .rectangle, - state: $store.textfieldState, - info: Constants.한글_영어_숫자_입력_문구, - maxLetter: 10, - focusState: $isFocused, - equals: true - ) + profileSection + nickNameSection Spacer() } } @@ -52,12 +44,20 @@ public extension NickNameSettingView { state: store.buttonState, action: { send(.저장_버튼_눌렀을때) } ) - .setKeyboardHeight() + .keyboardAnchor(store.isKeyboardVisible) } .padding(.horizontal, 20) .padding(.top, 16) .pokitNavigationBar { navigationBar } - .ignoresSafeArea(edges: .bottom) + .dismissKeyboard(focused: $isFocused) + .sheet(isPresented: $store.isProfileSheetPresented) { + PokitProfileBottomSheet( + selectedImage: store.selectedProfile, + images: store.profileImages, + delegateSend: { store.send(.scope(.profile($0))) } + ) + } + .ignoresSafeArea(.container, edges: .bottom) .task { await send(.뷰가_나타났을때).finish() } } } @@ -74,6 +74,65 @@ private extension NickNameSettingView { } .padding(.top, 8) } + @MainActor + var profileSection: some View { + LazyImage(url: URL(string: store.selectedProfile?.imageURL ?? "")) { state in + Group { + if let image = state.image { + image + .resizable() + .clipShape(.circle) + } else if state.isLoading { + PokitSpinner() + .foregroundStyle(.pokit(.icon(.brand))) + } else { + Image(.image(.profile)) + .resizable() + } + + } + .animation(.pokitDissolve, value: state.image) + } + .frame(width: 70, height: 70) + .overlay(alignment: .bottomTrailing) { + Button(action: { send(.프로필_설정_버튼_눌렀을때) }) { + Circle() + .strokeBorder(.pokit(.border(.secondary)), lineWidth: 1) + .background(Circle().foregroundColor(.white)) + .frame(width: 24, height: 24) + .overlay { + Image(.icon(.plus)) + .resizable() + .frame(width: 18, height: 18) + .foregroundStyle(.pokit(.icon(.secondary))) + } + .offset(x: 3.5, y: -3) + } + } + .padding(.vertical, 16) + } + var nickNameSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("닉네임 설정") + .pokitFont(.b2(.m)) + .foregroundStyle(.pokit(.text(.secondary))) + + PokitTextInput( + text: $store.text, + type: store.text.isEmpty ? .text : .iconR( + icon: .icon(.x), + action: { send(.닉네임지우기_버튼_눌렀을때) } + ), + shape: .rectangle, + state: $store.textfieldState, + placeholder: "닉네임을 입력해주세요.", + info: Constants.한글_영어_숫자_입력_문구, + maxLetter: 10, + focusState: $isFocused, + equals: true + ) + } + } } //MARK: - Preview @@ -81,7 +140,7 @@ private extension NickNameSettingView { NavigationStack { NickNameSettingView( store: Store( - initialState: .init(), + initialState: .init(user: BaseUserResponse.mock.toDomain()), reducer: { NickNameSettingFeature() } ) ) diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift index f08dadbc..e8945883 100644 --- a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift +++ b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingFeature.swift @@ -8,6 +8,7 @@ import Foundation import ComposableArchitecture import CoreKit +import Domain import Util @Reducer @@ -27,10 +28,14 @@ public struct PokitSettingFeature { var userDefaults @Dependency(AuthClient.self) var authClient + @Dependency(UserClient.self) + var userClient /// - State @ObservableState public struct State: Equatable { @Presents var nickNameSettingState: NickNameSettingFeature.State? + var user: BaseUser? + var isLogoutPresented: Bool = false var isWithdrawPresented: Bool = false var isWebViewPresented: Bool = false @@ -51,7 +56,9 @@ public struct PokitSettingFeature { public enum View: BindableAction, Equatable { case binding(BindingAction) case dismiss - case 닉네임설정 + case onAppear + + case 프로필설정 case 알림설정 case 공지사항 case 서비스_이용약관 @@ -61,17 +68,19 @@ public struct PokitSettingFeature { case 로그아웃_팝업_확인_눌렀을때 case 회원탈퇴_버튼_눌렀을때 case 회원탈퇴_팝업_확인_눌렀을때 - case 뷰가_나타났을때 } public enum InnerAction: Equatable { + case 닉네임_조회_API_반영(BaseUser) case 로그아웃_팝업(isPresented: Bool) case 회원탈퇴_팝업(isPresented: Bool) } public enum AsyncAction: Equatable { case 회원탈퇴_API + case 닉네임_조회_API case 키_제거_수행 + case 클립보드_감지 } public enum ScopeAction: Equatable { case 없음 } @@ -134,8 +143,8 @@ private extension PokitSettingFeature { case .dismiss: return .run { _ in await dismiss() } - case .닉네임설정: - state.nickNameSettingState = NickNameSettingFeature.State() + case .프로필설정: + state.nickNameSettingState = NickNameSettingFeature.State(user: state.user) return .none case .알림설정: @@ -181,19 +190,21 @@ private extension PokitSettingFeature { await send(.delegate(.회원탈퇴)) } - case .뷰가_나타났을때: - return .run { send in - for await _ in self.pasteboard.changes() { - let url = try await pasteboard.probableWebURL() - await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) - } - } + case .onAppear: + return .merge( + .send(.async(.닉네임_조회_API)), + .send(.async(.클립보드_감지)) + ) } } /// - Inner Effect func handleInnerAction(_ action: Action.InnerAction, state: inout State) -> Effect { switch action { + case let .닉네임_조회_API_반영(user): + state.user = user + return .none + case let .로그아웃_팝업(isPresented): state.isLogoutPresented = isPresented return .none @@ -247,6 +258,12 @@ private extension PokitSettingFeature { try await authClient.회원탈퇴(request) } + case .닉네임_조회_API: + return .run { send in + let result = try await userClient.닉네임_조회().toDomain() + await send(.inner(.닉네임_조회_API_반영(result))) + } + case .키_제거_수행: keychain.delete(.accessToken) keychain.delete(.refreshToken) @@ -256,6 +273,15 @@ private extension PokitSettingFeature { await userDefaults.removeString(.jwt) await userDefaults.removeString(.authPlatform) } + + case .클립보드_감지: + return .run { send in + let result = try await userClient.닉네임_조회().toDomain() + for await _ in self.pasteboard.changes() { + let url = try await pasteboard.probableWebURL() + await send(.delegate(.linkCopyDetected(url)), animation: .pokitSpring) + } + } } } diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift index b092f054..ac0f33ec 100644 --- a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift +++ b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import DSKit +import NukeUI @ViewAction(for: PokitSettingFeature.self) public struct PokitSettingView: View { @@ -25,9 +26,9 @@ public extension PokitSettingView { var body: some View { WithPerceptionTracking { VStack(spacing: 0) { - section1 - section2 - section3 + profileSection + menuSection + accountSection Spacer() } .padding(.top, 16) @@ -60,32 +61,59 @@ public extension PokitSettingView { ) { store in NickNameSettingView(store: store) } - .onAppear { send(.뷰가_나타났을때) } + .task { await send(.onAppear).finish() } } } } //MARK: - Configure View private extension PokitSettingView { @ViewBuilder - var section1: some View { - Section { - SettingItem( - title: "닉네임 설정", - action: { send(.닉네임설정) } - ) - - SettingItem( - title: "알림 설정", - action: { send(.알림설정) } + var profileSection: some View { + HStack(spacing: 12) { + LazyImage(url: URL(string: store.user?.profile?.imageURL ?? "")) { state in + Group { + if let image = state.image { + image + .resizable() + .clipShape(.circle) + } else if state.isLoading { + PokitSpinner() + .foregroundStyle(.pokit(.icon(.brand))) + } else { + Image(.image(.profile)) + .resizable() + } + + } + .animation(.pokitDissolve, value: state.image) + } + .frame(width: 40, height: 40) + Text(store.user?.nickname ?? "") + .pokitFont(.b1(.m)) + Spacer() + PokitTextButton( + "프로필 편집", + state: .stroke(.secondary), + size: .small, + shape: .rectangle, + action: { send(.프로필설정) } ) } + .padding(.top, 16) + .padding(.vertical, 8) + .padding(.horizontal, 20) PokitDivider() .padding(.vertical, 16) } @ViewBuilder - var section2: some View { + var menuSection: some View { Section { + SettingItem( + title: "알림 설정", + action: { send(.알림설정) } + ) + SettingItem( title: "공지사항", action: { send(.공지사항) } @@ -110,7 +138,7 @@ private extension PokitSettingView { .padding(.vertical, 16) } - var section3: some View { + var accountSection: some View { Section { SettingItem( title: "로그아웃", diff --git a/Projects/Feature/Resources/.gitkeep b/Projects/Feature/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Feature/Resources/Resource.swift b/Projects/Feature/Resources/Resource.swift deleted file mode 100644 index 3780fc99..00000000 --- a/Projects/Feature/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Source.stencil.swift -// Manifests -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/SharedThirdPartyLib/Resources/.gitkeep b/Projects/SharedThirdPartyLib/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/SharedThirdPartyLib/Resources/Resource.swift b/Projects/SharedThirdPartyLib/Resources/Resource.swift deleted file mode 100644 index 3780fc99..00000000 --- a/Projects/SharedThirdPartyLib/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Source.stencil.swift -// Manifests -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Util/Resources/.gitkeep b/Projects/Util/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Util/Resources/Resource.swift b/Projects/Util/Resources/Resource.swift deleted file mode 100644 index 3780fc99..00000000 --- a/Projects/Util/Resources/Resource.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Source.stencil.swift -// Manifests -// -// Created by 김도형 on 6/16/24. -// - -import Foundation diff --git a/Projects/Util/Sources/BaseInterestType.swift b/Projects/Util/Sources/BaseInterestType.swift new file mode 100644 index 00000000..6c942417 --- /dev/null +++ b/Projects/Util/Sources/BaseInterestType.swift @@ -0,0 +1,45 @@ +// +// BaseInterestType.swift +// Util +// +// Created by 김민호 on 1/6/25. +// + +public enum BaseInterestType: String, CaseIterable { + case `default` + case 스포츠_레저 + case 기획_마케팅 + case 쇼핑 + case 여행 + case 경제_시사 + case 영화_드라마 + case 장소 + case 인테리어 + case IT + case 디자인 + case 자기계발 + case 유머 + case 음악 + case 독서 + case 취업정보 + case 요리_레시피 + case 반려동물 + + public var title: String { + switch self { + case .스포츠_레저: return "스포츠/레저" + case .기획_마케팅: return "기획/마케팅" + case .경제_시사: return "경제/시사" + case .영화_드라마: return "영화/드라마" + case .요리_레시피: return "요리/레시피" + + default: return self.rawValue + } + } +} + +public extension String { + var slashConvertUnderBar: String { + return self.replacingOccurrences(of: "/", with: "_") + } +} diff --git a/Projects/Util/Sources/BaseOpenType.swift b/Projects/Util/Sources/BaseOpenType.swift new file mode 100644 index 00000000..326e1d0c --- /dev/null +++ b/Projects/Util/Sources/BaseOpenType.swift @@ -0,0 +1,13 @@ +// +// BaseOpenType.swift +// Util +// +// Created by 김민호 on 1/6/25. +// + +public enum BaseOpenType: String { + case 공개 = "PUBLIC" + case 비공개 = "PRIVATE" + + public var title: String { self.rawValue } +} diff --git a/Projects/Util/Sources/Constants.swift b/Projects/Util/Sources/Constants.swift index b354ffc6..4c12153f 100644 --- a/Projects/Util/Sources/Constants.swift +++ b/Projects/Util/Sources/Constants.swift @@ -12,6 +12,7 @@ public enum Constants { public static let userPath: String = "/api/v1/user" public static let authPath: String = "/api/v1/auth" public static let categoryPath: String = "/api/v1/category" + public static let categoryPathV2: String = "/api/v2/category" public static let contentPath: String = "/api/v1/content" public static let remindPath: String = "api/v1/remind" public static let alertPath: String = "/api/v1/alert" @@ -22,7 +23,7 @@ public enum Constants { public static let 개인정보_처리방침_주소: URL = URL(string: "https://www.notion.so/de3468b3be1744538c22a333ae1d0ec8")! public static let 마케팅_정보_수신_주소: URL = URL(string: "https://www.notion.so/bb6d0d6569204d5e9a7b67e5825f9d10")! public static let 고객문의_주소: URL = URL(string: "https://www.instagram.com/pokit.official/")! - public static let 기본_썸네일_주소: URL = URL(string: "https://pokit-storage.s3.ap-northeast-2.amazonaws.com/logo/pokit.png")! + public static let 기본_썸네일_주소: URL = URL(string: "https://pokit-s3.s3.ap-northeast-2.amazonaws.com/logo/pokit.png")! public static let 포킷_최대_갯수_문구: String = "최대 30개의 포킷을 생성할 수 있습니다.\n포킷을 삭제한 뒤에 추가해주세요." public static let 복사한_링크_저장하기_문구: String = "복사한 링크 저장하기" @@ -32,4 +33,6 @@ public enum Constants { public static let 한글_영어_숫자_입력_문구: String = "한글, 영어, 숫자로만 입력이 가능합니다." public static var mockImageUrl: String { "https://picsum.photos/\(Int.random(in: 150...250))" } + + public static let 미분류: String = "미분류" } diff --git a/Projects/Util/Sources/Extension/View+Extension.swift b/Projects/Util/Sources/Extension/View+Extension.swift index d0e7c9b0..ac165b50 100644 --- a/Projects/Util/Sources/Extension/View+Extension.swift +++ b/Projects/Util/Sources/Extension/View+Extension.swift @@ -7,33 +7,95 @@ import SwiftUI -extension View { - public func dismissKeyboard( +public extension View { + @ViewBuilder + func dismissKeyboard( focused: FocusState.Binding ) -> some View { self - .overlay { - if focused.wrappedValue { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - focused.wrappedValue = false - } - } + .contentShape(Rectangle()) + .onTapGesture { + guard focused.wrappedValue else { return } + focused.wrappedValue = false } } - public func dismissKeyboard( + @ViewBuilder + func dismissKeyboard( focused: FocusState.Binding ) -> some View { self - .overlay { - if focused.wrappedValue != nil { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - focused.wrappedValue = nil - } + .contentShape(Rectangle()) + .onTapGesture { + guard focused.wrappedValue != nil else { return } + focused.wrappedValue = nil + } + } + + @ViewBuilder + func overlay( + `if` condition: Bool, + alignment: Alignment = .center, + @ViewBuilder content: () -> some View, + @ViewBuilder `else`: () -> some View = { EmptyView() } + ) -> some View { + self + .overlay(alignment: alignment) { + if condition { + content() + } else { + `else`() + } + } + } + + @ViewBuilder + func overlay( + ifLet optional: T?, + alignment: Alignment = .center, + @ViewBuilder content: (T) -> some View, + @ViewBuilder `else`: () -> some View = { EmptyView() } + ) -> some View { + self + .overlay(alignment: alignment) { + if let optional { + content(optional) + } else { + `else`() + } + } + } + + @ViewBuilder + func background( + `if` condition: Bool, + alignment: Alignment = .center, + @ViewBuilder content: () -> some View, + @ViewBuilder `else`: () -> some View = { EmptyView() } + ) -> some View { + self + .background(alignment: alignment) { + if condition { + content() + } else { + `else`() + } + } + } + + @ViewBuilder + func background( + ifLet optional: T?, + alignment: Alignment = .center, + @ViewBuilder content: (T) -> some View, + @ViewBuilder `else`: () -> some View = { EmptyView() } + ) -> some View { + self + .background(alignment: alignment) { + if let optional { + content(optional) + } else { + `else`() } } } diff --git a/Projects/Util/Sources/Protocols/PokitCardItem.swift b/Projects/Util/Sources/Protocols/PokitCardItem.swift index 219864a0..ff753edc 100644 --- a/Projects/Util/Sources/Protocols/PokitCardItem.swift +++ b/Projects/Util/Sources/Protocols/PokitCardItem.swift @@ -19,3 +19,9 @@ public protocol CategoryImage { var id: Int { get } var imageURL: String { get } } + +public protocol Shareable { + var openType: BaseOpenType { get } + var keywordType: BaseInterestType { get } + var userCount: Int { get } +} diff --git a/Templates/Pokit_TCA.xctemplate/___FILEBASENAME___Feature.swift b/Templates/Pokit_TCA.xctemplate/___FILEBASENAME___Feature.swift index 24e3d927..aefa4481 100644 --- a/Templates/Pokit_TCA.xctemplate/___FILEBASENAME___Feature.swift +++ b/Templates/Pokit_TCA.xctemplate/___FILEBASENAME___Feature.swift @@ -95,4 +95,20 @@ private extension ___VARIABLE_sceneName___Feature { func handleDelegateAction(_ action: Action.DelegateAction, state: inout State) -> Effect { return .none } + + /// - Shared Effect + func shared(_ action: Action, state: inout State) -> Effect { + switch action { + case .view(let viewAction): + return handleViewAction(viewAction, state: &state) + case .inner(let innerAction): + return handleInnerAction(innerAction, state: &state) + case .async(let asyncAction): + return handleAsyncAction(asyncAction, state: &state) + case .scope(let scopeAction): + return handleScopeAction(scopeAction, state: &state) + case .delegate(let delegateAction): + return handleDelegateAction(delegateAction, state: &state) + } + } } diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 7f020ba1..d669a4e3 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -28,7 +28,7 @@ let package = Package( .package(url: "https://github.com/firebase/firebase-ios-sdk", "10.28.0" ..< "10.28.1"), .package(url: "https://github.com/Kitura/Swift-JWT", from: "4.0.1"), .package(url: "https://github.com/kean/Nuke", from: "12.8.0"), - .package(url: "https://github.com/scinfu/SwiftSoup", from: "2.7.4"), + .package(url: "https://github.com/scinfu/SwiftSoup", "2.7.0" ..< "2.7.5"), .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.22.5") ] ) diff --git a/Tuist/ProjectDescriptionHelpers/Feature.swift b/Tuist/ProjectDescriptionHelpers/Feature.swift index 7c2e86cf..dae2e2fd 100644 --- a/Tuist/ProjectDescriptionHelpers/Feature.swift +++ b/Tuist/ProjectDescriptionHelpers/Feature.swift @@ -12,7 +12,6 @@ public enum Feature: String, CaseIterable { case contentDetail = "ContentDetail" case contentSetting = "ContentSetting" case categorySetting = "CategorySetting" - case remind = "Remind" case login = "Login" case pokit = "Pokit" case categoryDetail = "CategoryDetail" @@ -20,6 +19,8 @@ public enum Feature: String, CaseIterable { case contentList = "ContentList" case categorySharing = "CategorySharing" case contentCard = "ContentCard" + case intro = "Intro" + case recommend = "Recommend" public var target: Target { return .makeTarget( @@ -35,15 +36,21 @@ public enum Feature: String, CaseIterable { } public var demoTarget: Target { + var dependencies: [TargetDependency] = [.target(self.target)] + if self != .login && self != .intro { + dependencies.append( + .project(target: "FeatureIntro", path: .relativeToRoot("Projects/Feature")) + ) + } + return .makeTarget( name: "Feature\(self.rawValue)Demo", product: .app, bundleName: "Feature.\(self.rawValue)Demo", infoPlist: .file(path: .relativeToRoot("Projects/App/Resources/Pokit-info.plist")), resources: ["Feature\(self.rawValue)Demo/Resources/**"], - dependencies: [ - .target(self.target) - ] + entitlements: .file(path: .relativeToRoot("Projects/App/ShareExtension/ShareExtension.entitlements")), + dependencies: dependencies ) } @@ -65,7 +72,6 @@ public enum Feature: String, CaseIterable { case .contentDetail: return [] case .contentSetting: return [] case .categorySetting: return [] - case .remind: return [] case .login: return [] case .pokit: return [ @@ -89,6 +95,11 @@ public enum Feature: String, CaseIterable { .project(target: "FeatureContentCard", path: .relativeToRoot("Projects/Feature")) ] case .contentCard: return [] + case .intro: + return [ + .project(target: "FeatureLogin", path: .relativeToRoot("Projects/Feature")) + ] + case .recommend: return [] } } } diff --git a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift index 486c516f..91bfa436 100644 --- a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift @@ -14,6 +14,7 @@ public extension Target { bundleName: String, infoPlist: InfoPlist? = nil, resources: ResourceFileElements? = nil, + entitlements: Entitlements? = nil, dependencies: [TargetDependency] ) -> Target { return .target( @@ -25,6 +26,7 @@ public extension Target { infoPlist: infoPlist, sources: ["\(name)/Sources/**"], resources: resources, + entitlements: entitlements, dependencies: dependencies, settings: .settings() ) diff --git a/Tuist/Templates/framework/framework.swift b/Tuist/Templates/framework/framework.swift index 31612017..e9cd095b 100644 --- a/Tuist/Templates/framework/framework.swift +++ b/Tuist/Templates/framework/framework.swift @@ -20,8 +20,8 @@ let frameworkTemplate = Template( .file( path: "Projects/\(frameworkProjectAttribute)/\(frameworkTargetAttribute)/Sources/Source.swift", templatePath: "Dummy.stencil"), - .file( - path: "Projects/\(frameworkProjectAttribute)/\(frameworkTargetAttribute)/Resources/Resource.swift", - templatePath: "Dummy.stencil") + .string( + path: "Projects/\(frameworkProjectAttribute)/\(frameworkTargetAttribute)/Resources/.gitkeep", + contents: "") ] ) diff --git a/Tuist/Templates/project/project.swift b/Tuist/Templates/project/project.swift index 938e3f3e..7ac92e02 100644 --- a/Tuist/Templates/project/project.swift +++ b/Tuist/Templates/project/project.swift @@ -22,8 +22,8 @@ let projectTemplate = Template( .file( path: "Projects/\(nameAttribute)/Sources/Source.swift", templatePath: "Dummy.stencil"), - .file( - path: "Projects/\(nameAttribute)/Resources/Resource.swift", - templatePath: "Dummy.stencil"), + .string( + path: "Projects/\(nameAttribute)/Resources/.gitkeep", + contents: ""), ] ) diff --git a/graph.png b/graph.png index 74e5b158..792d0f33 100644 Binary files a/graph.png and b/graph.png differ