diff --git a/Projects/App/Sources/MainTab/MainTabPath.swift b/Projects/App/Sources/MainTab/MainTabPath.swift index 087d1b2f..31547516 100644 --- a/Projects/App/Sources/MainTab/MainTabPath.swift +++ b/Projects/App/Sources/MainTab/MainTabPath.swift @@ -171,6 +171,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))))): 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/PokitCaution.swift b/Projects/DSKit/Sources/Components/PokitCaution.swift index fea55d24..bc977ccd 100644 --- a/Projects/DSKit/Sources/Components/PokitCaution.swift +++ b/Projects/DSKit/Sources/Components/PokitCaution.swift @@ -10,6 +10,7 @@ import SwiftUI public enum CautionType { case 카테고리없음 case 미분류_링크없음 + case 포킷상세_링크없음 case 링크없음 case 즐겨찾기_링크없음 case 링크부족 @@ -17,12 +18,13 @@ public enum CautionType { var image: PokitImage.Character { switch self { - case .카테고리없음, .링크없음, .즐겨찾기_링크없음, .미분류_링크없음: - return .empty case .링크부족: return .sad + case .알림없음: return .pooki + + default: return .empty } } @@ -32,7 +34,7 @@ public enum CautionType { return "저장된 포킷이 없어요!" case .미분류_링크없음: return "미분류 링크가 없어요!" - case .링크없음: + case .링크없음, .포킷상세_링크없음: return "저장된 링크가 없어요!" case .즐겨찾기_링크없음: return "즐겨찾기 링크가 없어요!" @@ -49,6 +51,8 @@ public enum CautionType { return "포킷을 생성해 링크를 저장해보세요" case .미분류_링크없음: return "링크를 포킷에 깔끔하게 분류하셨어요" + case .포킷상세_링크없음: + return "포킷에 링크를 저장해보세요" case .링크없음: return "다양한 링크를 한 곳에 저장해보세요" case .즐겨찾기_링크없음: @@ -64,7 +68,7 @@ public enum CautionType { switch self { case .카테고리없음: return "포킷 추가하기" - case .미분류_링크없음: + case .미분류_링크없음, .포킷상세_링크없음: return "링크 추가하기" default: return nil } 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/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift index b8a3f109..382a6386 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,7 +60,6 @@ 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 @@ -83,11 +85,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 +118,6 @@ public struct CategoryDetailFeature { public enum ScopeAction { case categoryBottomSheet(PokitBottomSheet.Delegate) case categoryDeleteBottomSheet(PokitDeleteBottomSheet.Delegate) - case filterBottomSheet(CategoryFilterSheet.Delegate) case contents(IdentifiedActionOf) } @@ -119,6 +125,7 @@ public struct CategoryDetailFeature { case contentItemTapped(BaseContentItem) case linkCopyDetected(URL?) case 링크수정(contentId: Int) + case 링크추가(categoryId: Int) case 포킷삭제 case 포킷수정(BaseCategoryItem) case 포킷공유 @@ -174,6 +181,45 @@ 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() + } else { + state.domain.condition.isUnreadFlitered.toggle() + } + 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 +234,6 @@ private extension CategoryDetailFeature { await send(.inner(.카테고리_선택_시트_활성화(false))) } - case .필터_버튼_눌렀을때: - state.isFilterSheetPresented.toggle() - return .none - case .dismiss: return .run { _ in await dismiss() } @@ -347,16 +389,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 +416,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 836ceefe..733c3676 100644 --- a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift +++ b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift @@ -11,6 +11,7 @@ import FeatureContentCard import Domain import DSKit import Util +import NukeUI @ViewAction(for: CategoryDetailFeature.self) public struct CategoryDetailView: View { @@ -27,18 +28,38 @@ public struct CategoryDetailView: View { public extension CategoryDetailView { var body: some View { WithPerceptionTracking { - VStack(spacing: 16) { + VStack(spacing: 24) { header + PokitDivider().padding(.horizontal, -20) + filterHeader contentScrollView } .padding(.horizontal, 20) .padding(.top, 12) .pokitNavigationBar { navigationBar } + //TODO: overlay(condition) merge 시 수정 + .overlay(alignment: .bottomTrailing) { + if !store.contents.isEmpty { + 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 +82,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() } } } @@ -83,18 +96,35 @@ private extension CategoryDetailView { 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,23 +135,94 @@ 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)) + } + 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 + if !store.contents.isEmpty { + HStack(spacing: isFavoriteCategory ? 2 : 8) { + if isFavoriteCategory { + Image(.icon(.link)) + .resizable() + .frame(width: 16, height: 16) + .foregroundStyle(.pokit(.icon(.secondary))) + Text("\(store.contents.count)개") + .foregroundStyle(.pokit(.text(.tertiary))) + .pokitFont(.b2(.m)) + } else { + PokitTextButton( + "즐겨찾기", + state: store.isFavoriteFiltered + ? .filled(.primary) + : .default(.secondary), + size: .small, + shape: .round, + action: { send(.분류_버튼_눌렀을때(.즐겨찾기)) } + ) + PokitTextButton( + "안읽음", + state: store.isUnreadFiltered + ? .filled(.primary) + : .default(.secondary), + size: .small, + shape: .round, + action: { send(.분류_버튼_눌렀을때(.안읽음)) } + ) + } + + Spacer() + PokitIconLTextLink( + store.sortType.title, + icon: .icon(.align), + action: { send(.정렬_버튼_눌렀을때) } ) + .contentTransition(.numericText()) } } } @@ -131,8 +232,10 @@ private extension CategoryDetailView { if !store.isLoading { if store.contents.isEmpty { VStack { - PokitCaution(type: .링크없음) - .padding(.top, 20) + PokitCaution( + type: .포킷상세_링크없음, + action: { send(.링크_추가_버튼_눌렀을때) } + ) Spacer() } @@ -204,8 +307,8 @@ private extension CategoryDetailView { id: 0, userId: 0, categoryName: "포킷", - categoryImage: .init(imageId: 0, imageURL: ""), - contentCount: 16, + categoryImage: .init(imageId: 0, imageURL: Constants.mockImageUrl), + contentCount: 16, createdAt: "", //TODO: v2 property 수정 openType: .비공개, diff --git a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift index 4190dbb6..b4541e77 100644 --- a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift +++ b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift @@ -212,6 +212,7 @@ private extension ContentSettingFeature { return .send(.delegate(.포킷추가하기)) case .뒤로가기_버튼_눌렀을때: + state.categoryId = nil return state.isShareExtension ? .send(.delegate(.dismiss)) : .run { _ in await dismiss() }