From ff58753beca2bab79544eb54285688344c3b5da1 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 22 Jan 2026 21:42:33 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20WorkshopView=20-=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B0=80=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Workshop 레이아웃에 사용되는 매직넘버를 상수로 분리 - 그라데이션 오버레이 분리 - Task 초기화 로직 뷰모델로 이전 --- .../ViewModels/WorkshopViewModel.swift | 21 ++++- .../Main/WorkshopStickyHeaderSection.swift | 4 +- .../Views/Main/WorkshopTopBannerSection.swift | 4 +- .../Workshop/Views/Main/WorkshopView.swift | 85 ++++++++++--------- 4 files changed, 71 insertions(+), 43 deletions(-) diff --git a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift index c8603b576..e3f3c5524 100644 --- a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift +++ b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift @@ -189,7 +189,26 @@ class WorkshopViewModel { // isWorkshopBannerLoading은 항상 true로 시작 // NukeAnimatedImageView가 GIF 로드 완료 시 자동으로 false로 변경 } - + + // MARK: - Initialization + + /// 초기 데이터 로드 (네트워크 체크 → 카테고리 로드 → 백그라운드 프리페칭) + func initialize() async { + // 네트워크 체크 + guard NetworkManager.shared.isConnected else { + hasNetworkError = true + return + } + + // 1. 현재 선택된 카테고리만 먼저 로드 (빠른 초기 화면) + await fetchDataForCategory(selectedCategory) + + // 2. 백그라운드에서 나머지 카테고리 프리페칭 + Task.detached(priority: .background) { [weak self] in + await self?.prefetchRemainingData() + } + } + // MARK: - Firebase Methods (통합) /// 특정 카테고리의 데이터만 가져오기 func fetchDataForCategory(_ category: String) async { diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopStickyHeaderSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopStickyHeaderSection.swift index b25903c65..86495dc1a 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopStickyHeaderSection.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopStickyHeaderSection.swift @@ -27,7 +27,9 @@ extension WorkshopView { .padding(.bottom, 20) .background(.white) .clipShape(.rect(cornerRadii: .init(topLeading: 20, topTrailing: 20))) - .offset(y: max(120, min(730, viewModel.mainContentOffset - 20))) + .offset(y: max(WorkshopLayout.stickyHeaderMinOffset, + min(WorkshopLayout.stickyHeaderMaxOffset, + viewModel.mainContentOffset - WorkshopLayout.stickyHeaderOffsetAdjust))) } /// 필터바 diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift index 378b10de3..4f80a26e5 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift @@ -28,11 +28,11 @@ extension WorkshopView { Spacer() myItemBtn } - .padding(.top, 60) + .padding(.top, WorkshopLayout.topPadding) .padding(.horizontal, 20) .padding(.bottom, 24) .background(Color.white100) - .opacity(viewModel.mainContentOffset - 80 < 70 ? 1 : 0) + .opacity(viewModel.mainContentOffset - WorkshopLayout.titleBarOpacityThreshold < WorkshopLayout.titleBarOpacityRange ? 1 : 0) } /// 타이틀 뷰 diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift index 7397d42d9..ebe4e5981 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift @@ -8,6 +8,20 @@ import SwiftUI import NukeUI +// MARK: - Layout Constants + +enum WorkshopLayout { + static let topPadding: CGFloat = 60 + static let recentTemplateTopSpacing: CGFloat = 106 + static let mainContentTopSpacing: CGFloat = 43 + static let gradientHeight: CGFloat = 100 + static let stickyHeaderMinOffset: CGFloat = 120 + static let stickyHeaderMaxOffset: CGFloat = 730 + static let stickyHeaderOffsetAdjust: CGFloat = 20 + static let titleBarOpacityThreshold: CGFloat = 80 + static let titleBarOpacityRange: CGFloat = 70 +} + // MARK: - Main View struct WorkshopView: View { @@ -46,22 +60,7 @@ struct WorkshopView: View { stickyHeaderSection // 상단 그라데이션 블러 오버레이 - VStack { - LinearGradient( - colors: [ - Color.white.opacity(0.8), - Color.white.opacity(0.6), - Color.white.opacity(0.3), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 100) - .ignoresSafeArea(edges: .top) - Spacer() - } - .allowsHitTesting(false) + topGradientOverlay } .background( Image(.workshopKeyringBGB) @@ -76,26 +75,12 @@ struct WorkshopView: View { sortSheet } .task { - // 네트워크 체크 - guard NetworkManager.shared.isConnected else { - viewModel.hasNetworkError = true - return - } + guard !hasInitialized else { return } - // 최초 한 번만 초기화 - if !hasInitialized { - viewModel = WorkshopViewModel(userManager: userManager) - hasInitialized = true + viewModel = WorkshopViewModel(userManager: userManager) + hasInitialized = true - // 1. 현재 선택된 카테고리만 먼저 로드 (빠른 초기 화면) - await viewModel.fetchDataForCategory(viewModel.selectedCategory) - // Workshop 배너는 Home에서 이미 prefetch됨 - - // 2. 백그라운드에서 나머지 카테고리 프리페칭 - Task.detached(priority: .background) { - await viewModel.prefetchRemainingData() - } - } + await viewModel.initialize() } .onChange(of: viewModel.selectedCategory) { oldValue, newValue in viewModel.resetFilters() @@ -108,8 +93,30 @@ struct WorkshopView: View { .withToast(position: .tabbar) } - // MARK: Main Content - + // MARK: - Overlay + + /// 상단 그라데이션 오버레이 + var topGradientOverlay: some View { + VStack { + LinearGradient( + colors: [ + Color.white.opacity(0.8), + Color.white.opacity(0.6), + Color.white.opacity(0.3), + Color.clear + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: WorkshopLayout.gradientHeight) + .ignoresSafeArea(edges: .top) + Spacer() + } + .allowsHitTesting(false) + } + + // MARK: - Main Content + /// 메인 스크롤 콘텐츠 var mainScrollContent: some View { ScrollView(showsIndicators: false) { @@ -118,13 +125,13 @@ struct WorkshopView: View { topBannerSection Spacer() - .frame(height: 106) + .frame(height: WorkshopLayout.recentTemplateTopSpacing) // 최근 사용 템플릿 recentTemplateSection Spacer() - .frame(height: 43) + .frame(height: WorkshopLayout.mainContentTopSpacing) // 메인 콘텐츠 (그리드) mainContentSection @@ -141,7 +148,7 @@ struct WorkshopView: View { } ) } - .padding(.top, 60) + .padding(.top, WorkshopLayout.topPadding) .background(alignment: .top) { Image(.workshopKeyringBGF) .resizable() From 8f13fa1dfa8a85878878f36cc77aaa5c8f280b93 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 22 Jan 2026 22:17:13 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20Workshop=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC=20-=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - +TopBanner -> TOpSection - GridHelper -> GridBuilder - MyItemsView -> WorkshopItemsView (근데 내 아이템뷰 사라지긴함) - WorkshopPreview -> WorkshopItemDetailView (프리뷰보다 직관적인 네이밍으로 변경) - 위 파일들에 대한 참조 변경 --- Keychy/Keychy.xcodeproj/project.pbxproj | 56 +++++++------ .../Presentation/Tab/Views/WorkshopTab.swift | 12 +-- .../Views/Components/WorkshopComponents.swift | 4 +- .../WorkshopGridBuilder.swift} | 6 +- ...iew.swift => WorkshopItemDetailView.swift} | 8 +- ...msView.swift => WorkshopMyItemsView.swift} | 12 +-- .../Views/Main/WorkshopTemplatesView.swift | 2 +- ...n.swift => WorkshopView+MainContent.swift} | 6 +- .../Main/WorkshopView+NetworkError.swift | 35 ++++++++ .../Views/Main/WorkshopView+SortSheet.swift | 23 ++++++ ....swift => WorkshopView+StickyHeader.swift} | 2 +- ...on.swift => WorkshopView+TopSection.swift} | 35 +++++++- .../Workshop/Views/Main/WorkshopView.swift | 79 ------------------- 13 files changed, 149 insertions(+), 131 deletions(-) rename Keychy/Keychy/Presentation/Workshop/Views/{Main/WorkshopGridHelpers.swift => Components/WorkshopGridBuilder.swift} (96%) rename Keychy/Keychy/Presentation/Workshop/Views/Main/{WorkshopPreview.swift => WorkshopItemDetailView.swift} (98%) rename Keychy/Keychy/Presentation/Workshop/Views/Main/{MyItemsView.swift => WorkshopMyItemsView.swift} (97%) rename Keychy/Keychy/Presentation/Workshop/Views/Main/{WorkshopMainContentSection.swift => WorkshopView+MainContent.swift} (97%) create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+NetworkError.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+SortSheet.swift rename Keychy/Keychy/Presentation/Workshop/Views/Main/{WorkshopStickyHeaderSection.swift => WorkshopView+StickyHeader.swift} (96%) rename Keychy/Keychy/Presentation/Workshop/Views/Main/{WorkshopTopBannerSection.swift => WorkshopView+TopSection.swift} (67%) diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 83242c0e5..cce5b39b4 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -117,14 +117,8 @@ 4C3688132EC08A3100C64E75 /* HancomMalangMalang-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = 4C3688112EC08A3100C64E75 /* HancomMalangMalang-Bold.otf */; }; 4C3688142EC08A3100C64E75 /* HancomMalangMalang-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = 4C3688122EC08A3100C64E75 /* HancomMalangMalang-Regular.otf */; }; 4C4733232F1FA2AB005D2376 /* WorkshopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47331E2F1FA2AB005D2376 /* WorkshopView.swift */; }; - 4C4733242F1FA2AB005D2376 /* WorkshopPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733212F1FA2AB005D2376 /* WorkshopPreview.swift */; }; - 4C4733252F1FA2AB005D2376 /* MyItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733202F1FA2AB005D2376 /* MyItemsView.swift */; }; - 4C4733262F1FA2AB005D2376 /* WorkshopGridHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733182F1FA2AB005D2376 /* WorkshopGridHelpers.swift */; }; 4C4733272F1FA2AB005D2376 /* WorkshopBundleBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47331A2F1FA2AB005D2376 /* WorkshopBundleBanner.swift */; }; 4C4733282F1FA2AB005D2376 /* WorkshopTemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47331C2F1FA2AB005D2376 /* WorkshopTemplatesView.swift */; }; - 4C4733292F1FA2AB005D2376 /* WorkshopMainContentSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733192F1FA2AB005D2376 /* WorkshopMainContentSection.swift */; }; - 4C47332A2F1FA2AB005D2376 /* WorkshopStickyHeaderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47331B2F1FA2AB005D2376 /* WorkshopStickyHeaderSection.swift */; }; - 4C47332B2F1FA2AB005D2376 /* WorkshopTopBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47331D2F1FA2AB005D2376 /* WorkshopTopBannerSection.swift */; }; 4C47332C2F1FA2AB005D2376 /* WorkshopComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733142F1FA2AB005D2376 /* WorkshopComponents.swift */; }; 4C47332D2F1FA2AB005D2376 /* WorkshopViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733162F1FA2AB005D2376 /* WorkshopViewModel.swift */; }; 4C4733942F1FA388005D2376 /* PixelDrawView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733772F1FA388005D2376 /* PixelDrawView.swift */; }; @@ -198,6 +192,14 @@ 4C4733D82F1FA388005D2376 /* AcrylicPhotoPreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47335A2F1FA388005D2376 /* AcrylicPhotoPreView.swift */; }; 4C4733D92F1FA388005D2376 /* TemplatePreviewComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733392F1FA388005D2376 /* TemplatePreviewComponents.swift */; }; 4C4733E52F20FE34005D2376 /* WorkshopRecentTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733E42F20FE34005D2376 /* WorkshopRecentTemplate.swift */; }; + 4C4733EB2F22553F005D2376 /* WorkshopView+StickyHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733E92F22553F005D2376 /* WorkshopView+StickyHeader.swift */; }; + 4C4733EC2F22553F005D2376 /* WorkshopView+MainContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733E62F22553F005D2376 /* WorkshopView+MainContent.swift */; }; + 4C4733ED2F22553F005D2376 /* WorkshopView+NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733E72F22553F005D2376 /* WorkshopView+NetworkError.swift */; }; + 4C4733EE2F22553F005D2376 /* WorkshopView+SortSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733E82F22553F005D2376 /* WorkshopView+SortSheet.swift */; }; + 4C4733F12F22563D005D2376 /* WorkshopView+TopSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733F02F22563D005D2376 /* WorkshopView+TopSection.swift */; }; + 4C4733F32F225781005D2376 /* WorkshopMyItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733F22F225781005D2376 /* WorkshopMyItemsView.swift */; }; + 4C4733F52F225785005D2376 /* WorkshopGridBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733F42F225785005D2376 /* WorkshopGridBuilder.swift */; }; + 4C4733F72F225A2C005D2376 /* WorkshopItemDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733F62F225A2C005D2376 /* WorkshopItemDetailView.swift */; }; 4C65303B2EBA5FA0000F8154 /* CheckmarkAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C65303A2EBA5FA0000F8154 /* CheckmarkAlert.swift */; }; 4C65303E2EBA6042000F8154 /* ImageSaveAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C65303D2EBA6042000F8154 /* ImageSaveAlert.swift */; }; 4C6530442EBA8077000F8154 /* PurchaseSuccessAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6530432EBA8077000F8154 /* PurchaseSuccessAlert.swift */; }; @@ -548,15 +550,9 @@ 4C3688122EC08A3100C64E75 /* HancomMalangMalang-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "HancomMalangMalang-Regular.otf"; sourceTree = ""; }; 4C4733142F1FA2AB005D2376 /* WorkshopComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopComponents.swift; sourceTree = ""; }; 4C4733162F1FA2AB005D2376 /* WorkshopViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopViewModel.swift; sourceTree = ""; }; - 4C4733182F1FA2AB005D2376 /* WorkshopGridHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopGridHelpers.swift; sourceTree = ""; }; - 4C4733192F1FA2AB005D2376 /* WorkshopMainContentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopMainContentSection.swift; sourceTree = ""; }; 4C47331A2F1FA2AB005D2376 /* WorkshopBundleBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopBundleBanner.swift; sourceTree = ""; }; - 4C47331B2F1FA2AB005D2376 /* WorkshopStickyHeaderSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopStickyHeaderSection.swift; sourceTree = ""; }; 4C47331C2F1FA2AB005D2376 /* WorkshopTemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopTemplatesView.swift; sourceTree = ""; }; - 4C47331D2F1FA2AB005D2376 /* WorkshopTopBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopTopBannerSection.swift; sourceTree = ""; }; 4C47331E2F1FA2AB005D2376 /* WorkshopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopView.swift; sourceTree = ""; }; - 4C4733202F1FA2AB005D2376 /* MyItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyItemsView.swift; sourceTree = ""; }; - 4C4733212F1FA2AB005D2376 /* WorkshopPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopPreview.swift; sourceTree = ""; }; 4C47332E2F1FA388005D2376 /* KeyringViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringViewModelProtocol.swift; sourceTree = ""; }; 4C47332F2F1FA388005D2376 /* KeyringViewModelProtocol+Reset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyringViewModelProtocol+Reset.swift"; sourceTree = ""; }; 4C4733332F1FA388005D2376 /* AudioRecorderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderManager.swift; sourceTree = ""; }; @@ -628,6 +624,14 @@ 4C47338C2F1FA388005D2376 /* SpeechBubbleFrameSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechBubbleFrameSelectorView.swift; sourceTree = ""; }; 4C47338D2F1FA388005D2376 /* SpeechBubblePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechBubblePreview.swift; sourceTree = ""; }; 4C4733E42F20FE34005D2376 /* WorkshopRecentTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopRecentTemplate.swift; sourceTree = ""; }; + 4C4733E62F22553F005D2376 /* WorkshopView+MainContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkshopView+MainContent.swift"; sourceTree = ""; }; + 4C4733E72F22553F005D2376 /* WorkshopView+NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkshopView+NetworkError.swift"; sourceTree = ""; }; + 4C4733E82F22553F005D2376 /* WorkshopView+SortSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkshopView+SortSheet.swift"; sourceTree = ""; }; + 4C4733E92F22553F005D2376 /* WorkshopView+StickyHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkshopView+StickyHeader.swift"; sourceTree = ""; }; + 4C4733F02F22563D005D2376 /* WorkshopView+TopSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkshopView+TopSection.swift"; sourceTree = ""; }; + 4C4733F22F225781005D2376 /* WorkshopMyItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopMyItemsView.swift; sourceTree = ""; }; + 4C4733F42F225785005D2376 /* WorkshopGridBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopGridBuilder.swift; sourceTree = ""; }; + 4C4733F62F225A2C005D2376 /* WorkshopItemDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopItemDetailView.swift; sourceTree = ""; }; 4C65303A2EBA5FA0000F8154 /* CheckmarkAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckmarkAlert.swift; sourceTree = ""; }; 4C65303D2EBA6042000F8154 /* ImageSaveAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaveAlert.swift; sourceTree = ""; }; 4C6530432EBA8077000F8154 /* PurchaseSuccessAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseSuccessAlert.swift; sourceTree = ""; }; @@ -1079,6 +1083,7 @@ 4C4733152F1FA2AB005D2376 /* Components */ = { isa = PBXGroup; children = ( + 4C4733F42F225785005D2376 /* WorkshopGridBuilder.swift */, 4C4733142F1FA2AB005D2376 /* WorkshopComponents.swift */, ); path = Components; @@ -1096,13 +1101,14 @@ isa = PBXGroup; children = ( 4C47331E2F1FA2AB005D2376 /* WorkshopView.swift */, - 4C47331D2F1FA2AB005D2376 /* WorkshopTopBannerSection.swift */, - 4C4733192F1FA2AB005D2376 /* WorkshopMainContentSection.swift */, - 4C47331B2F1FA2AB005D2376 /* WorkshopStickyHeaderSection.swift */, + 4C4733F02F22563D005D2376 /* WorkshopView+TopSection.swift */, + 4C4733E92F22553F005D2376 /* WorkshopView+StickyHeader.swift */, + 4C4733E82F22553F005D2376 /* WorkshopView+SortSheet.swift */, + 4C4733E62F22553F005D2376 /* WorkshopView+MainContent.swift */, + 4C4733E72F22553F005D2376 /* WorkshopView+NetworkError.swift */, + 4C4733F62F225A2C005D2376 /* WorkshopItemDetailView.swift */, 4C47331C2F1FA2AB005D2376 /* WorkshopTemplatesView.swift */, - 4C4733182F1FA2AB005D2376 /* WorkshopGridHelpers.swift */, - 4C4733202F1FA2AB005D2376 /* MyItemsView.swift */, - 4C4733212F1FA2AB005D2376 /* WorkshopPreview.swift */, + 4C4733F22F225781005D2376 /* WorkshopMyItemsView.swift */, ); path = Main; sourceTree = ""; @@ -2467,6 +2473,7 @@ 386B17522ECCDFBA00CCCC23 /* KeyringCollectView.swift in Sources */, 4CEBB15F2EFAD3D600CF53E2 /* MainTabViewModel.swift in Sources */, 4C65303B2EBA5FA0000F8154 /* CheckmarkAlert.swift in Sources */, + 4C4733F12F22563D005D2376 /* WorkshopView+TopSection.swift in Sources */, AA69DD2C2F14C80900C0A41C /* BundleViewModel+CaptureScene.swift in Sources */, 4C004FB12F187CFC00D9063E /* CollectionKeyringDetailView+VideoGen.swift in Sources */, 4C004FBB2F19F1FE00D9063E /* RootViewModel+ReviewCheck.swift in Sources */, @@ -2526,6 +2533,7 @@ 4C4733B92F1FA388005D2376 /* KeyringInfoInputView+TagManagement.swift in Sources */, 4C4733BA2F1FA388005D2376 /* AcrylicPhotoVM.swift in Sources */, 4C4733BB2F1FA388005D2376 /* CameraPicker.swift in Sources */, + 4C4733F32F225781005D2376 /* WorkshopMyItemsView.swift in Sources */, 4C4733BC2F1FA388005D2376 /* PixelVM+ImageConversion.swift in Sources */, 4C4733BD2F1FA388005D2376 /* AcrylicPhotoGuiding.swift in Sources */, 4C4733BE2F1FA388005D2376 /* PolaroidVM+Effect.swift in Sources */, @@ -2572,19 +2580,14 @@ 4CF2A9662F0B8C5800BA9FDA /* PullToRefreshIndicator.swift in Sources */, 4CC3D36E2EC2801F0009D376 /* WelcomeKeyringViewModel.swift in Sources */, 4CC3D36F2EC2801F0009D376 /* IntroViewModel+WelcomeKeyring.swift in Sources */, + 4C4733F52F225785005D2376 /* WorkshopGridBuilder.swift in Sources */, 38173D112EB90CCE00E36F7E /* TagInputPopup.swift in Sources */, 4CEBB1722EFE66CF00CF53E2 /* ToastManager.swift in Sources */, C6B56F232EC0341B0049F969 /* KeyringImageCache.swift in Sources */, AA6298522EC233D2001576C0 /* BundleEditView.swift in Sources */, 4C4733232F1FA2AB005D2376 /* WorkshopView.swift in Sources */, - 4C4733242F1FA2AB005D2376 /* WorkshopPreview.swift in Sources */, - 4C4733252F1FA2AB005D2376 /* MyItemsView.swift in Sources */, - 4C4733262F1FA2AB005D2376 /* WorkshopGridHelpers.swift in Sources */, 4C4733272F1FA2AB005D2376 /* WorkshopBundleBanner.swift in Sources */, 4C4733282F1FA2AB005D2376 /* WorkshopTemplatesView.swift in Sources */, - 4C4733292F1FA2AB005D2376 /* WorkshopMainContentSection.swift in Sources */, - 4C47332A2F1FA2AB005D2376 /* WorkshopStickyHeaderSection.swift in Sources */, - 4C47332B2F1FA2AB005D2376 /* WorkshopTopBannerSection.swift in Sources */, 4C47332C2F1FA2AB005D2376 /* WorkshopComponents.swift in Sources */, 4C47332D2F1FA2AB005D2376 /* WorkshopViewModel.swift in Sources */, 386B17642ECD142600CCCC23 /* String+Extension.swift in Sources */, @@ -2675,6 +2678,11 @@ 4CEC62332EAE08DA0099ECEE /* KeyringBundleItem.swift in Sources */, AA69DD242F14C56F00C0A41C /* BundleViewModel+CRUD.swift in Sources */, AA3908F82EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift in Sources */, + 4C4733EB2F22553F005D2376 /* WorkshopView+StickyHeader.swift in Sources */, + 4C4733F72F225A2C005D2376 /* WorkshopItemDetailView.swift in Sources */, + 4C4733EC2F22553F005D2376 /* WorkshopView+MainContent.swift in Sources */, + 4C4733ED2F22553F005D2376 /* WorkshopView+NetworkError.swift in Sources */, + 4C4733EE2F22553F005D2376 /* WorkshopView+SortSheet.swift in Sources */, AA0219DE2EB1C041006EF269 /* BundleNameInputView.swift in Sources */, 4CEC62342EAE08DA0099ECEE /* Spacing.swift in Sources */, 38A596732EAFA8D20003D712 /* KeychyUser.swift in Sources */, diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index 5e142c549..a4b3ab83e 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -40,20 +40,20 @@ struct WorkshopTab: View { // MARK: - 공통 프리뷰 case .workshopPreview(let item): if let template = item.base as? KeyringTemplate { - WorkshopPreview(router: router, viewModel: workshopViewModel, item: template) + WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: template) } else if let background = item.base as? Background { - WorkshopPreview(router: router, viewModel: workshopViewModel, item: background) + WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: background) } else if let carabiner = item.base as? Carabiner { - WorkshopPreview(router: router, viewModel: workshopViewModel, item: carabiner) + WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: carabiner) } else if let particle = item.base as? Particle { - WorkshopPreview(router: router, viewModel: workshopViewModel, item: particle) + WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: particle) } else if let sound = item.base as? Sound { - WorkshopPreview(router: router, viewModel: workshopViewModel, item: sound) + WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: sound) } // MARK: - 내 창고뷰 case .myItems: - MyItemsView(router: router) + WorkshopMyItemsView(router: router) // MARK: - 템플릿 목록뷰 case .workshopTemplates: diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift index e32a97d60..c5ac744aa 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift @@ -197,7 +197,7 @@ struct WorkshopItemView: View { } } - /// 탭 핸들러 (키링은 바로 만들기, 나머지는 WorkshopPreview로 이동) + /// 탭 핸들러 (키링은 바로 만들기, 나머지는 WorkshopItemDetailView로 이동) private func handleTap() { // 네트워크 체크 guard NetworkManager.shared.isConnected else { @@ -215,7 +215,7 @@ struct WorkshopItemView: View { return } - // 나머지 아이템들은 WorkshopPreview로 이동 + // 나머지 아이템들은 WorkshopItemDetailView로 이동 if let background = item as? Background { router.push(.workshopPreview(item: AnyHashable(background))) } else if let carabiner = item as? Carabiner { diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopGridHelpers.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift similarity index 96% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopGridHelpers.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift index d39bdeeb6..e421fbf01 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopGridHelpers.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift @@ -1,5 +1,5 @@ // -// WorkshopGridHelpers.swift +// WorkshopGridBuilder.swift // Keychy // // Created by Rundo on 11/4/25. @@ -7,8 +7,8 @@ import SwiftUI -/// 워크샵 그리드 레이아웃 헬퍼 함수들 -struct WorkshopGridHelpers { +/// 워크샵 그리드 레이아웃 빌더 +struct WorkshopGridBuilder { /// 통합 아이템 그리드 뷰 (제네릭 타입) static func itemGridView( diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopPreview.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift similarity index 98% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopPreview.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift index e3424c069..101b4e4b1 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopPreview.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift @@ -1,5 +1,5 @@ // -// WorkshopPreview.swift +// WorkshopItemDetailView.swift // Keychy // // Created by rundo on 11/3/25. @@ -9,7 +9,7 @@ import SwiftUI import Lottie import FirebaseFirestore -struct WorkshopPreview: View { +struct WorkshopItemDetailView: View { @Bindable var router: NavigationRouter @Environment(UserManager.self) private var userManager @State private var effectManager = EffectManager.shared @@ -174,7 +174,7 @@ struct WorkshopPreview: View { } // MARK: - Item Preview Section -extension WorkshopPreview { +extension WorkshopItemDetailView { private var itemPreview: some View { VStack { Spacer() @@ -301,7 +301,7 @@ extension WorkshopPreview { } // MARK: - Action Button Section -extension WorkshopPreview { +extension WorkshopItemDetailView { private var actionButton: some View { WorkshopItemActionButton( item: item, diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/MyItemsView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMyItemsView.swift similarity index 97% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/MyItemsView.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMyItemsView.swift index db4945fd7..b3b506c37 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/MyItemsView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMyItemsView.swift @@ -1,5 +1,5 @@ // -// MyItemsView.swift +// WorkshopMyItemsView.swift // Keychy // // Created by rundo on 10/30/25. @@ -7,7 +7,7 @@ import SwiftUI -struct MyItemsView: View { +struct WorkshopMyItemsView: View { @Bindable var router: NavigationRouter @Environment(UserManager.self) private var userManager @@ -143,7 +143,7 @@ struct MyItemsView: View { Group { switch viewModel.selectedCategory { case "템플릿": - WorkshopGridHelpers.itemGridView( + WorkshopGridBuilder.itemGridView( items: filteredOwnedTemplates, isOwnedCheck: { _ in false }, router: router, @@ -151,7 +151,7 @@ struct MyItemsView: View { emptyView: emptyContentView ) case "배경": - WorkshopGridHelpers.itemGridView( + WorkshopGridBuilder.itemGridView( items: filteredOwnedBackgrounds, isOwnedCheck: { _ in false }, router: router, @@ -159,7 +159,7 @@ struct MyItemsView: View { emptyView: emptyContentView ) case "카라비너": - WorkshopGridHelpers.itemGridView( + WorkshopGridBuilder.itemGridView( items: filteredOwnedCarabiners, isOwnedCheck: { _ in false }, router: router, @@ -167,7 +167,7 @@ struct MyItemsView: View { emptyView: emptyContentView ) case "이펙트": - WorkshopGridHelpers.effectGridView( + WorkshopGridBuilder.effectGridView( items: filteredOwnedEffects, isSoundOwned: { _ in false }, isParticleOwned: { _ in false }, diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTemplatesView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTemplatesView.swift index e3de0d2cb..d8e491c1a 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTemplatesView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTemplatesView.swift @@ -140,7 +140,7 @@ struct WorkshopTemplatesView: View { if filteredTemplates.isEmpty { emptyContentView } else { - WorkshopGridHelpers.itemGridView( + WorkshopGridBuilder.itemGridView( items: filteredTemplates, isOwnedCheck: { _ in false }, router: router, diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMainContentSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift similarity index 97% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMainContentSection.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift index 48d2c5af9..51cfd2833 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMainContentSection.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift @@ -1,5 +1,5 @@ // -// WorkshopMainContentSection.swift +// WorkshopView+MainContent.swift // Keychy // // Created by rundo on 11/3/25. @@ -63,7 +63,7 @@ extension WorkshopView { /// 이펙트 전용 콘텐츠 (사운드 + 파티클) var effectContentView: some View { - WorkshopGridHelpers.effectGridView( + WorkshopGridBuilder.effectGridView( items: viewModel.filteredEffects, isSoundOwned: viewModel.isSoundOwned, isParticleOwned: viewModel.isParticleOwned, @@ -78,7 +78,7 @@ extension WorkshopView { items: [T], isOwnedCheck: @escaping (T) -> Bool ) -> some View { - WorkshopGridHelpers.itemGridView( + WorkshopGridBuilder.itemGridView( items: items, isOwnedCheck: isOwnedCheck, router: router, diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+NetworkError.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+NetworkError.swift new file mode 100644 index 000000000..2ea28752f --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+NetworkError.swift @@ -0,0 +1,35 @@ +// +// WorkshopView+NetworkError.swift +// Keychy +// +// Created by rundo on 11/3/25. +// + +import SwiftUI + +// MARK: - Network Error + +extension WorkshopView { + /// 네트워크 에러 화면 + var networkErrorView: some View { + ZStack(alignment: .top) { + NoInternetView(topPadding: getSafeAreaTop() + 40, onRetry: { + Task { + await viewModel.retryFetchAllData() + } + }) + .ignoresSafeArea() + + // 고정 타이틀 바 (항상 표시) + HStack { + titleView + Spacer() + myItemBtn + } + .padding(.top, 60) + .padding(.horizontal, 20) + .padding(.bottom, 24) + .background(Color.white100) + } + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+SortSheet.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+SortSheet.swift new file mode 100644 index 000000000..20bc1722f --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+SortSheet.swift @@ -0,0 +1,23 @@ +// +// WorkshopView+SortSheet.swift +// Keychy +// +// Created by rundo on 11/3/25. +// + +import SwiftUI + +// MARK: - Sort Sheet + +extension WorkshopView { + /// 정렬 선택 시트 + var sortSheet: some View { + WorkshopSortSheet( + showSheet: $viewModel.showFilterSheet, + sortOrder: $viewModel.sortOrder + ) + .onChange(of: viewModel.sortOrder) { oldValue, newValue in + viewModel.applySorting() + } + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopStickyHeaderSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+StickyHeader.swift similarity index 96% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopStickyHeaderSection.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+StickyHeader.swift index 86495dc1a..18ffbdd0c 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopStickyHeaderSection.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+StickyHeader.swift @@ -1,5 +1,5 @@ // -// WorkshopStickyHeaderSection.swift +// WorkshopView+StickyHeader.swift // Keychy // // Created by rundo on 11/3/25. diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift similarity index 67% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift index 4f80a26e5..93a839030 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift @@ -1,5 +1,5 @@ // -// WorkshopTopBannerSection.swift +// WorkshopView+TopSection.swift // Keychy // // Created by rundo on 11/3/25. @@ -7,7 +7,7 @@ import SwiftUI -// MARK: - Top Banner Section +// MARK: - Top Section extension WorkshopView { /// 상단 배너 (코인 버튼 + 타이틀) @@ -80,4 +80,35 @@ extension WorkshopView { .fixedSize(horizontal: true, vertical: true) .buttonStyle(.glass) } + + /// 상단 그라데이션 오버레이 + var topGradientOverlay: some View { + VStack { + LinearGradient( + colors: [ + Color.white.opacity(0.8), + Color.white.opacity(0.6), + Color.white.opacity(0.3), + Color.clear + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: WorkshopLayout.gradientHeight) + .ignoresSafeArea(edges: .top) + Spacer() + } + .allowsHitTesting(false) + } + + /// 최근 사용 템플릿 섹션 + var recentTemplateSection: some View { + WorkshopRecentTemplate( + templates: viewModel.recentTemplates, + isLoading: viewModel.isLoading + ) { template in + // 템플릿 상세 프리뷰로 이동 + router.push(.workshopPreview(item: template)) + } + } } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift index ebe4e5981..7371e8445 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift @@ -93,30 +93,7 @@ struct WorkshopView: View { .withToast(position: .tabbar) } - // MARK: - Overlay - - /// 상단 그라데이션 오버레이 - var topGradientOverlay: some View { - VStack { - LinearGradient( - colors: [ - Color.white.opacity(0.8), - Color.white.opacity(0.6), - Color.white.opacity(0.3), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: WorkshopLayout.gradientHeight) - .ignoresSafeArea(edges: .top) - Spacer() - } - .allowsHitTesting(false) - } - // MARK: - Main Content - /// 메인 스크롤 콘텐츠 var mainScrollContent: some View { ScrollView(showsIndicators: false) { @@ -158,59 +135,3 @@ struct WorkshopView: View { } } -// MARK: - Sort Sheet - -extension WorkshopView { - /// 정렬 선택 시트 - var sortSheet: some View { - WorkshopSortSheet( - showSheet: $viewModel.showFilterSheet, - sortOrder: $viewModel.sortOrder - ) - .onChange(of: viewModel.sortOrder) { oldValue, newValue in - viewModel.applySorting() - } - } -} - -// MARK: - Recent Template Section - -extension WorkshopView { - /// 최근 사용 템플릿 섹션 - var recentTemplateSection: some View { - WorkshopRecentTemplate( - templates: viewModel.recentTemplates, - isLoading: viewModel.isLoading - ) { template in - // 템플릿 상세 프리뷰로 이동 - router.push(.workshopPreview(item: template)) - } - } -} - -// MARK: - Network Error - -extension WorkshopView { - /// 네트워크 에러 화면 - private var networkErrorView: some View { - ZStack(alignment: .top) { - NoInternetView(topPadding: getSafeAreaTop() + 40, onRetry: { - Task { - await viewModel.retryFetchAllData() - } - }) - .ignoresSafeArea() - - // 고정 타이틀 바 (항상 표시) - HStack { - titleView - Spacer() - myItemBtn - } - .padding(.top, 60) - .padding(.horizontal, 20) - .padding(.bottom, 24) - .background(Color.white100) - } - } -} From 6ea5b79648c13995a342a2e934cb34bd0278162a Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 22 Jan 2026 22:53:08 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20WorkshopComponents.swift=20->?= =?UTF-8?q?=20=EA=B1=B0=EC=9D=98=20=EC=B4=9D=EA=B8=B0=EB=B6=84=ED=95=B4?= =?UTF-8?q?=EA=B8=89=EC=9C=BC=EB=A1=9C=20=ED=95=B4=EC=B2=B4=EC=87=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkshopSortSheet.swift (SortOption 포함) - WorkshopFilterBar.swift - WorkshopFilterChip.swift - WorkshopSkeletonBox.swift - WorkshopItemCard.swift (PriceOverlay, SoundPlayButton, DownloadProgressRing 포함) - WorkshopItemActionButton.swift --- .../Components/TemplateActionButton.swift | 70 ++ .../TemplatePreviewComponents.swift | 2 +- .../Views/Components/WorkshopComponents.swift | 638 ------------------ .../Views/Components/WorkshopFilterBar.swift | 108 +++ .../Views/Components/WorkshopFilterChip.swift | 37 + .../Components/WorkshopGridBuilder.swift | 6 +- .../Components/WorkshopItemActionButton.swift | 70 ++ .../Views/Components/WorkshopItemCard.swift | 279 ++++++++ .../Components/WorkshopSkeletonBox.swift | 47 ++ .../Views/Components/WorkshopSortSheet.swift | 80 +++ 10 files changed, 695 insertions(+), 642 deletions(-) create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplateActionButton.swift delete mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterBar.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterChip.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopSkeletonBox.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopSortSheet.swift diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplateActionButton.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplateActionButton.swift new file mode 100644 index 000000000..f63e2ba3d --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplateActionButton.swift @@ -0,0 +1,70 @@ +// +// TemplateActionButton.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI + +// MARK: - Template Action Button + +/// 키링 템플릿 전용 액션 버튼 +/// - 보유중이면 "만들기" 버튼 표시 (활성화) +/// - 유료이고 미보유면 구매 버튼 표시 +/// - 무료이고 미보유면 "만들기" 버튼 표시 (활성화) +struct TemplateActionButton: View { + let template: KeyringTemplate + let isOwned: Bool + let onMake: () -> Void + let onPurchase: () -> Void + + var body: some View { + Group { + if isOwned || template.isFree { + // 보유중이거나 무료인 경우 만들기 버튼 (활성화) + makeButton + } else { + // 유료이고 미보유인 경우 구매 버튼 + purchaseButton + } + } + } + + /// 만들기 버튼 (활성화) + private var makeButton: some View { + Button { + onMake() + } label: { + Text("만들기") + .typography(.suit17B) + .foregroundStyle(.white100) + .frame(maxWidth: .infinity) + .padding(.vertical, 7.5) + } + .buttonStyle(.glassProminent) + .tint(.main500) + } + + /// 구매 버튼 (유료) + private var purchaseButton: some View { + Button { + onPurchase() + } label: { + HStack(spacing: 5) { + Image(.myCoinMini) + .resizable() + .scaledToFit() + .frame(width: 32) + + Text("\(template.workshopPrice)") + .typography(.nanum18EB) + .foregroundStyle(.white100) + } + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.glassProminent) + .tint(.black80) + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplatePreviewComponents.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplatePreviewComponents.swift index dbe84bcee..e4de01c81 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplatePreviewComponents.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplatePreviewComponents.swift @@ -208,7 +208,7 @@ extension TemplatePreviewBody { private var actionButton: some View { Group { if let template { - KeyringTemplateActionButton( + TemplateActionButton( template: template, isOwned: isOwned, onMake: checkInventoryAndMake, diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift deleted file mode 100644 index c5ac744aa..000000000 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift +++ /dev/null @@ -1,638 +0,0 @@ -// -// WorkshopComponents.swift -// Keychy -// -// Created by rundo on 10/31/25. -// - -import SwiftUI -import NukeUI -import Lottie - -// MARK: - Filter Components - -/// 필터 칩 버튼 -struct FilterChip: View { - let title: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button { - action() - } label: { - HStack(spacing: 4) { - Text(title) - .typography(.suit14SB18) - .foregroundColor(isSelected ? Color(.systemBackground) : .gray500) - } - .padding(.horizontal, Spacing.gap) - .padding(.vertical, Spacing.sm) - .frame(height: 34) - .background( - RoundedRectangle(cornerRadius: 15) - .fill(isSelected ? Color.black70 : Color.gray50) - ) - } - .buttonStyle(PlainButtonStyle()) - } -} - -// MARK: - Sort Components - -/// 정렬 옵션 행 -struct SortOption: View { - let title: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack { - Text(title) - .typography(.suit16M) - .foregroundColor(.black100) - - Spacer() - -// if isSelected { -// Image(systemName: "checkmark") -// .foregroundStyle(.pink) -// } - } - .padding() - } - } -} - -/// 정렬 선택 시트 -struct WorkshopSortSheet: View { - @Binding var showSheet: Bool - @Binding var sortOrder: String - - var body: some View { - VStack(spacing: 0) { - // 헤더 - HStack { - Button { - showSheet = false - } label: { - Image(.dismissGray600) - .resizable() - .frame(width: 24, height: 24) - } - - Spacer() - - Text("정렬 기준") - .typography(.suit15B25) - - Spacer() - - Color.clear - .frame(width: 24) - } - .padding() - - // 정렬 옵션 - VStack(spacing: 0) { - ForEach(["최신순", "인기순"], id: \.self) { sort in - SortOption( - title: sort, - isSelected: sortOrder == sort - ) { - sortOrder = sort - showSheet = false - } - } - } - - Spacer() - } - .presentationDetents([.height(200)]) - } -} - -// MARK: - Item Views - -/// 모든 워크샵 아이템을 표시하는 통합 그리드 아이템 뷰 -struct WorkshopItemView: View { - let item: Item - var isOwned: Bool = false - var router: NavigationRouter? = nil - var viewModel: WorkshopViewModel? = nil - - @State private var isParticleReady = false - @State private var effectManager = EffectManager.shared - @Environment(UserManager.self) private var userManager - - var body: some View { - Button { - handleTap() - } label: { - VStack(spacing: 8) { - // 썸네일 이미지 - thumbnailImage - - // 아이템 이름 - Text(item.name) - .typography(.suit14SB18) - } - } - .buttonStyle(.plain) - } - - /// 썸네일 이미지 + 가격 오버레이 - private var thumbnailImage: some View { - ZStack(alignment: .top) { - // Particle일 경우 Lottie 애니메이션, Sound는 이미지 - if let particle = item as? Particle { - if let particleId = item.id { - if isParticleReady { - LottieView(name: particleId, loopMode: .loop, speed: 1.0) - .frame(width: twoGridCellWidth, height: itemHeight) - .clipped() - } else { - LoadingAlert(type: .short40, message: nil) - .task { - await ensureParticleReady(particle) - } - } - } - } else { - // Sound, Background, Carabiner, 키링 등은 기존처럼 이미지로 처리 (GIF 지원) - SimpleAnimatedImage(url: item.thumbnailURL) - .aspectRatio(contentMode: item is Carabiner || item is KeyringTemplate ? .fit : .fill) - .padding(.horizontal, item is Carabiner ? 5 : 0) - .padding(.vertical, item is KeyringTemplate ? 10 : 0) - .clipped() - .frame(width: twoGridCellWidth, height: itemHeight) - } - - // 가격 오버레이 - PriceOverlay( - isFree: item.isFree, - price: item.workshopPrice, - isOwned: isOwned, - item: item, - effectManager: effectManager, - userManager: userManager - ) - } - .frame(width: twoGridCellWidth, height: itemHeight) - .background(Color.gray50) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.gray50, lineWidth: 2) - ) - } - - /// 아이템 타입에 따른 높이 계산 - private var itemHeight: CGFloat { - if item is KeyringTemplate || item is Background { - return twoGridCellHeight - } else { - return twoSquareGridCellSize - } - } - - /// 탭 핸들러 (키링은 바로 만들기, 나머지는 WorkshopItemDetailView로 이동) - private func handleTap() { - // 네트워크 체크 - guard NetworkManager.shared.isConnected else { - ToastManager.shared.show() - return - } - - guard let router = router else { return } - - // 키링 템플릿일 경우 해당 Preview로 이동 - if let template = item as? KeyringTemplate, - let templateId = template.id, - let route = WorkshopRoute.from(string: templateId) { - router.push(route) - return - } - - // 나머지 아이템들은 WorkshopItemDetailView로 이동 - if let background = item as? Background { - router.push(.workshopPreview(item: AnyHashable(background))) - } else if let carabiner = item as? Carabiner { - router.push(.workshopPreview(item: AnyHashable(carabiner))) - } else if let particle = item as? Particle { - router.push(.workshopPreview(item: AnyHashable(particle))) - } else if let sound = item as? Sound { - router.push(.workshopPreview(item: AnyHashable(sound))) - } - } - - private func ensureParticleReady(_ particle: Particle) async { - guard let particleId = particle.id else { return } - - // 이미 캐시 또는 Bundle에 있으면 바로 준비 완료 - if effectManager.isInCache(particleId: particleId) || effectManager.isInBundle(particleId: particleId) { - isParticleReady = true - return - } - - // 다운로드 필요 - await effectManager.downloadParticle(particle, userManager: userManager) - - isParticleReady = true - } -} - -/// 공통 가격 오버레이 (유료 표시) -struct PriceOverlay: View { - let isFree: Bool - let price: Int - let isOwned: Bool - let item: Item - let effectManager: EffectManager - let userManager: UserManager - - var body: some View { - ZStack { - // 유료 아이콘 - VStack { - HStack { - Image(.paidIcon) - Spacer() - } - .padding(.top, 7) - .padding(.leading, 10) - Spacer() - } - .opacity(isFree ? 0 : 1) - - // 보유 표시 - VStack { - HStack { - Spacer() - Text("보유") - .typography(.suit13M) - .foregroundStyle(.white100) - .padding(.vertical, 4) - .padding(.horizontal, 10) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(.black60) - ) - } - .padding(10) - Spacer() - } - .opacity(isOwned ? 1 : 0) - } - - // 사운드인 경우에만 재생 버튼 표시 - if item is Sound { - VStack { - Spacer() - - HStack { - Spacer() - - EffectButtonStyle( - item: item, - effectManager: effectManager, - userManager: userManager - ) - .padding(8) - } - } - } - } -} - -struct EffectButtonStyle: View { - let item: Item - let effectManager: EffectManager - let userManager: UserManager - - private var itemId: String { - item.id ?? "" - } - - private var isDownloading: Bool { - effectManager.downloadingItemIds.contains(itemId) - } - - private var progress: Double { - effectManager.downloadProgress[itemId] ?? 0.0 - } - - var body: some View { - Button { - Task { - if let sound = item as? Sound { - await effectManager.playSound(sound, userManager: userManager) - } - } - } label: { - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(.gray50) - .frame(width: 38, height: 38) - .overlay( - RoundedRectangle(cornerRadius: 10) - .inset(by: 0.5) - .stroke(.white100, lineWidth: 1) - ) - .shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 4) - - if isDownloading { - // 다운로드 중이면 프로그레스 표시 - CircularProgressView(progress: progress) - .frame(width: 25, height: 25) - } else { - Image(.polygon) - .resizable() - .scaledToFit() - .frame(width: 14, height: 14) - .offset(x: 1) - } - } - } - .disabled(isDownloading) - } -} - -/// 원형 프로그레스 뷰 -struct CircularProgressView: View { - let progress: Double - - var body: some View { - ZStack { - Circle() - .stroke(Color.gray300, lineWidth: 2) - - Circle() - .trim(from: 0, to: progress) - .stroke(.white100, lineWidth: 2) - .rotationEffect(.degrees(-90)) - } - } -} - -// MARK: - Action Buttons - -/// 키링 템플릿 전용 액션 버튼 -/// - 보유중이면 "만들기" 버튼 표시 (활성화) -/// - 유료이고 미보유면 구매 버튼 표시 -/// - 무료이고 미보유면 "만들기" 버튼 표시 (활성화) -struct KeyringTemplateActionButton: View { - let template: KeyringTemplate - let isOwned: Bool - let onMake: () -> Void - let onPurchase: () -> Void - - var body: some View { - Group { - if isOwned || template.isFree { - // 보유중이거나 무료인 경우 만들기 버튼 (활성화) - makeButton - } else { - // 유료이고 미보유인 경우 구매 버튼 - purchaseButton - } - } - } - - /// 만들기 버튼 (활성화) - private var makeButton: some View { - Button { - onMake() - } label: { - Text("만들기") - .typography(.suit17B) - .foregroundStyle(.white100) - .frame(maxWidth: .infinity) - .padding(.vertical, 7.5) - } - .buttonStyle(.glassProminent) - .tint(.main500) - } - - /// 구매 버튼 (유료) - private var purchaseButton: some View { - Button { - onPurchase() - } label: { - HStack(spacing: 5) { - Image(.myCoinMini) - .resizable() - .scaledToFit() - .frame(width: 32) - - Text("\(template.workshopPrice)") - .typography(.nanum18EB) - .foregroundStyle(.white100) - } - .frame(maxWidth: .infinity) - .frame(height: 36) - } - .buttonStyle(.glassProminent) - .tint(.black80) - } -} - -/// WorkshopItem (배경, 카라비너, 이펙트 등) 전용 액션 버튼 -/// - 무료면 "무료" 비활성화 버튼 -/// - 보유중이면 "보유중" 비활성화 버튼 -/// - 유료이고 미보유면 구매 버튼 -struct WorkshopItemActionButton: View { - let item: any WorkshopItem - let isOwned: Bool - let onPurchase: () -> Void - - var body: some View { - Group { - if item.isFree { - disabledButton(text: "무료") - } else if isOwned { - disabledButton(text: "보유중") - } else { - purchaseButton - } - } - } - - /// 비활성화 버튼 (무료 / 보유중) - private func disabledButton(text: String) -> some View { - Button { - // 비활성화 - 아무 동작 없음 - } label: { - Text(text) - .typography(.suit17B) - .foregroundStyle(.gray400) - .frame(maxWidth: .infinity) - .padding(.vertical, 7.5) - } - .buttonStyle(.glassProminent) - .tint(.white100) - .disabled(true) - } - - /// 구매 버튼 (유료) - private var purchaseButton: some View { - Button { - onPurchase() - } label: { - HStack(spacing: 5) { - Image(.myCoinMini) - .resizable() - .scaledToFit() - .frame(width: 32) - - Text("\(item.workshopPrice)") - .typography(.nanum18EB) - .foregroundStyle(.white100) - } - .frame(maxWidth: .infinity) - .frame(height: 36) - } - .buttonStyle(.glassProminent) - .tint(.black80) - } -} - -// MARK: - Filter Bar - -/// 워크샵 필터바 공통 컴포넌트 -struct WorkshopFilterBar: View { - @Binding var viewModel: WorkshopViewModel - - var body: some View { - HStack(spacing: 8) { - // 정렬 버튼 (고정) - sortButton - - // 카테고리별 필터 (스크롤 가능) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - categorySpecificFilters - } - } - } - .padding(.top, 12) - } - - /// 정렬 버튼 - private var sortButton: some View { - Button { - viewModel.showFilterSheet = true - } label: { - HStack(spacing: 4) { - Text(viewModel.sortOrder) - .typography(.suit14SB18) - .foregroundColor(.gray500) - - Image(systemName: "chevron.down") - .foregroundColor(.gray500) - } - .padding(.horizontal, Spacing.gap) - .padding(.vertical, Spacing.sm) - .frame(height: 34) - .background( - RoundedRectangle(cornerRadius: 15) - .fill(.gray50) - ) - } - .buttonStyle(PlainButtonStyle()) - } - - /// 카테고리별 필터 옵션 - private var categorySpecificFilters: some View { - Group { - switch viewModel.selectedCategory { - case "템플릿": - ForEach(TemplateFilterType.allCases, id: \.self) { filter in - FilterChip( - title: filter.rawValue, - isSelected: viewModel.selectedTemplateFilter == filter - ) { - viewModel.selectedTemplateFilter = - viewModel.selectedTemplateFilter == filter ? nil : filter - } - } - - case "이펙트": - ForEach(EffectFilterType.allCases, id: \.self) { filter in - FilterChip( - title: filter.rawValue, - isSelected: viewModel.selectedEffectFilter == filter - ) { - viewModel.selectedEffectFilter = - viewModel.selectedEffectFilter == filter ? nil : filter - } - } - - case "카라비너": - ForEach(viewModel.availableCarabinerTags, id: \.self) { tag in - FilterChip( - title: tag, - isSelected: viewModel.selectedCommonFilter == tag - ) { - viewModel.selectedCommonFilter = - viewModel.selectedCommonFilter == tag ? nil : tag - } - } - - case "배경": - ForEach(viewModel.availableBackgroundTags, id: \.self) { tag in - FilterChip( - title: tag, - isSelected: viewModel.selectedCommonFilter == tag - ) { - viewModel.selectedCommonFilter = - viewModel.selectedCommonFilter == tag ? nil : tag - } - } - - default: - EmptyView() - } - } - } -} - -// MARK: - Skeleton Loading View - -struct SkeletonBox: View { - let width: CGFloat - let height: CGFloat - - @State private var isAnimating = false - - var body: some View { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray50) - .frame(width: width, height: height) - .overlay( - RoundedRectangle(cornerRadius: 10) - .fill( - LinearGradient( - gradient: Gradient(colors: [ - Color.clear, - Color.white.opacity(0.5), - Color.clear - ]), - startPoint: .leading, - endPoint: .trailing - ) - ) - .offset(x: isAnimating ? width : -width) - .animation( - Animation.linear(duration: 1.5) - .repeatForever(autoreverses: false), - value: isAnimating - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .onAppear { - isAnimating = true - } - } -} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterBar.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterBar.swift new file mode 100644 index 000000000..197c39f72 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterBar.swift @@ -0,0 +1,108 @@ +// +// WorkshopFilterBar.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI + +// MARK: - Filter Bar + +/// 워크샵 필터바 공통 컴포넌트 +struct WorkshopFilterBar: View { + @Binding var viewModel: WorkshopViewModel + + var body: some View { + HStack(spacing: 8) { + // 정렬 버튼 (고정) + sortButton + + // 카테고리별 필터 (스크롤 가능) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + categorySpecificFilters + } + } + } + .padding(.top, 12) + } + + /// 정렬 버튼 + private var sortButton: some View { + Button { + viewModel.showFilterSheet = true + } label: { + HStack(spacing: 4) { + Text(viewModel.sortOrder) + .typography(.suit14SB18) + .foregroundColor(.gray500) + + Image(systemName: "chevron.down") + .foregroundColor(.gray500) + } + .padding(.horizontal, Spacing.gap) + .padding(.vertical, Spacing.sm) + .frame(height: 34) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(.gray50) + ) + } + .buttonStyle(PlainButtonStyle()) + } + + /// 카테고리별 필터 옵션 + private var categorySpecificFilters: some View { + Group { + switch viewModel.selectedCategory { + case "템플릿": + ForEach(TemplateFilterType.allCases, id: \.self) { filter in + WorkshopFilterChip( + title: filter.rawValue, + isSelected: viewModel.selectedTemplateFilter == filter + ) { + viewModel.selectedTemplateFilter = + viewModel.selectedTemplateFilter == filter ? nil : filter + } + } + + case "이펙트": + ForEach(EffectFilterType.allCases, id: \.self) { filter in + WorkshopFilterChip( + title: filter.rawValue, + isSelected: viewModel.selectedEffectFilter == filter + ) { + viewModel.selectedEffectFilter = + viewModel.selectedEffectFilter == filter ? nil : filter + } + } + + case "카라비너": + ForEach(viewModel.availableCarabinerTags, id: \.self) { tag in + WorkshopFilterChip( + title: tag, + isSelected: viewModel.selectedCommonFilter == tag + ) { + viewModel.selectedCommonFilter = + viewModel.selectedCommonFilter == tag ? nil : tag + } + } + + case "배경": + ForEach(viewModel.availableBackgroundTags, id: \.self) { tag in + WorkshopFilterChip( + title: tag, + isSelected: viewModel.selectedCommonFilter == tag + ) { + viewModel.selectedCommonFilter = + viewModel.selectedCommonFilter == tag ? nil : tag + } + } + + default: + EmptyView() + } + } + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterChip.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterChip.swift new file mode 100644 index 000000000..d01c068ec --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterChip.swift @@ -0,0 +1,37 @@ +// +// WorkshopFilterChip.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI + +// MARK: - Filter Chip + +/// 필터 칩 버튼 +struct WorkshopFilterChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button { + action() + } label: { + HStack(spacing: 4) { + Text(title) + .typography(.suit14SB18) + .foregroundColor(isSelected ? Color(.systemBackground) : .gray500) + } + .padding(.horizontal, Spacing.gap) + .padding(.vertical, Spacing.sm) + .frame(height: 34) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(isSelected ? Color.black70 : Color.gray50) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift index e421fbf01..b6f8b7c91 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift @@ -27,7 +27,7 @@ struct WorkshopGridBuilder { GridItem(.flexible()) ], spacing: 11) { ForEach(items) { item in - WorkshopItemView( + WorkshopItemCard( item: item, isOwned: isOwnedCheck(item), router: router, @@ -61,7 +61,7 @@ struct WorkshopGridBuilder { ], spacing: 11) { ForEach(Array(items.enumerated()), id: \.offset) { index, item in if let sound = item as? Sound { - WorkshopItemView( + WorkshopItemCard( item: sound, isOwned: isSoundOwned(sound), router: router, @@ -69,7 +69,7 @@ struct WorkshopGridBuilder { ) .id(sound.id) } else if let particle = item as? Particle { - WorkshopItemView( + WorkshopItemCard( item: particle, isOwned: isParticleOwned(particle), router: router, diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift new file mode 100644 index 000000000..676f97b88 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift @@ -0,0 +1,70 @@ +// +// WorkshopItemActionButton.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI + +// MARK: - Item Action Button + +/// WorkshopItem (배경, 카라비너, 이펙트 등) 전용 액션 버튼 +/// - 무료면 "무료" 비활성화 버튼 +/// - 보유중이면 "보유중" 비활성화 버튼 +/// - 유료이고 미보유면 구매 버튼 +struct WorkshopItemActionButton: View { + let item: any WorkshopItem + let isOwned: Bool + let onPurchase: () -> Void + + var body: some View { + Group { + if item.isFree { + disabledButton(text: "무료") + } else if isOwned { + disabledButton(text: "보유중") + } else { + purchaseButton + } + } + } + + /// 비활성화 버튼 (무료 / 보유중) + private func disabledButton(text: String) -> some View { + Button { + // 비활성화 - 아무 동작 없음 + } label: { + Text(text) + .typography(.suit17B) + .foregroundStyle(.gray400) + .frame(maxWidth: .infinity) + .padding(.vertical, 7.5) + } + .buttonStyle(.glassProminent) + .tint(.white100) + .disabled(true) + } + + /// 구매 버튼 (유료) + private var purchaseButton: some View { + Button { + onPurchase() + } label: { + HStack(spacing: 5) { + Image(.myCoinMini) + .resizable() + .scaledToFit() + .frame(width: 32) + + Text("\(item.workshopPrice)") + .typography(.nanum18EB) + .foregroundStyle(.white100) + } + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.glassProminent) + .tint(.black80) + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift new file mode 100644 index 000000000..d9f52956c --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift @@ -0,0 +1,279 @@ +// +// WorkshopItemCard.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI +import NukeUI +import Lottie + +// MARK: - Item Card + +/// 모든 워크샵 아이템을 표시하는 통합 그리드 아이템 카드 +struct WorkshopItemCard: View { + let item: Item + var isOwned: Bool = false + var router: NavigationRouter? = nil + var viewModel: WorkshopViewModel? = nil + + @State private var isParticleReady = false + @State private var effectManager = EffectManager.shared + @Environment(UserManager.self) private var userManager + + var body: some View { + Button { + handleTap() + } label: { + VStack(spacing: 8) { + // 썸네일 이미지 + thumbnailImage + + // 아이템 이름 + Text(item.name) + .typography(.suit14SB18) + } + } + .buttonStyle(.plain) + } + + /// 썸네일 이미지 + 가격 오버레이 + private var thumbnailImage: some View { + ZStack(alignment: .top) { + // Particle일 경우 Lottie 애니메이션, Sound는 이미지 + if let particle = item as? Particle { + if let particleId = item.id { + if isParticleReady { + LottieView(name: particleId, loopMode: .loop, speed: 1.0) + .frame(width: twoGridCellWidth, height: itemHeight) + .clipped() + } else { + LoadingAlert(type: .short40, message: nil) + .task { + await ensureParticleReady(particle) + } + } + } + } else { + // Sound, Background, Carabiner, 키링 등은 기존처럼 이미지로 처리 (GIF 지원) + SimpleAnimatedImage(url: item.thumbnailURL) + .aspectRatio(contentMode: item is Carabiner || item is KeyringTemplate ? .fit : .fill) + .padding(.horizontal, item is Carabiner ? 5 : 0) + .padding(.vertical, item is KeyringTemplate ? 10 : 0) + .clipped() + .frame(width: twoGridCellWidth, height: itemHeight) + } + + // 가격 오버레이 + WorkshopPriceOverlay( + isFree: item.isFree, + price: item.workshopPrice, + isOwned: isOwned, + item: item, + effectManager: effectManager, + userManager: userManager + ) + } + .frame(width: twoGridCellWidth, height: itemHeight) + .background(Color.gray50) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.gray50, lineWidth: 2) + ) + } + + /// 아이템 타입에 따른 높이 계산 + private var itemHeight: CGFloat { + if item is KeyringTemplate || item is Background { + return twoGridCellHeight + } else { + return twoSquareGridCellSize + } + } + + /// 탭 핸들러 (키링은 바로 만들기, 나머지는 WorkshopItemDetailView로 이동) + private func handleTap() { + // 네트워크 체크 + guard NetworkManager.shared.isConnected else { + ToastManager.shared.show() + return + } + + guard let router = router else { return } + + // 키링 템플릿일 경우 해당 Preview로 이동 + if let template = item as? KeyringTemplate, + let templateId = template.id, + let route = WorkshopRoute.from(string: templateId) { + router.push(route) + return + } + + // 나머지 아이템들은 WorkshopItemDetailView로 이동 + if let background = item as? Background { + router.push(.workshopPreview(item: AnyHashable(background))) + } else if let carabiner = item as? Carabiner { + router.push(.workshopPreview(item: AnyHashable(carabiner))) + } else if let particle = item as? Particle { + router.push(.workshopPreview(item: AnyHashable(particle))) + } else if let sound = item as? Sound { + router.push(.workshopPreview(item: AnyHashable(sound))) + } + } + + private func ensureParticleReady(_ particle: Particle) async { + guard let particleId = particle.id else { return } + + // 이미 캐시 또는 Bundle에 있으면 바로 준비 완료 + if effectManager.isInCache(particleId: particleId) || effectManager.isInBundle(particleId: particleId) { + isParticleReady = true + return + } + + // 다운로드 필요 + await effectManager.downloadParticle(particle, userManager: userManager) + + isParticleReady = true + } +} + +// MARK: - Price Overlay + +/// 공통 가격 오버레이 (유료 표시) +struct WorkshopPriceOverlay: View { + let isFree: Bool + let price: Int + let isOwned: Bool + let item: Item + let effectManager: EffectManager + let userManager: UserManager + + var body: some View { + ZStack { + // 유료 아이콘 + VStack { + HStack { + Image(.paidIcon) + Spacer() + } + .padding(.top, 7) + .padding(.leading, 10) + Spacer() + } + .opacity(isFree ? 0 : 1) + + // 보유 표시 + VStack { + HStack { + Spacer() + Text("보유") + .typography(.suit13M) + .foregroundStyle(.white100) + .padding(.vertical, 4) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.black60) + ) + } + .padding(10) + Spacer() + } + .opacity(isOwned ? 1 : 0) + } + + // 사운드인 경우에만 재생 버튼 표시 + if item is Sound { + VStack { + Spacer() + + HStack { + Spacer() + + SoundPlayButton( + item: item, + effectManager: effectManager, + userManager: userManager + ) + .padding(8) + } + } + } + } +} + +// MARK: - Sound Play Button + +struct SoundPlayButton: View { + let item: Item + let effectManager: EffectManager + let userManager: UserManager + + private var itemId: String { + item.id ?? "" + } + + private var isDownloading: Bool { + effectManager.downloadingItemIds.contains(itemId) + } + + private var progress: Double { + effectManager.downloadProgress[itemId] ?? 0.0 + } + + var body: some View { + Button { + Task { + if let sound = item as? Sound { + await effectManager.playSound(sound, userManager: userManager) + } + } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(.gray50) + .frame(width: 38, height: 38) + .overlay( + RoundedRectangle(cornerRadius: 10) + .inset(by: 0.5) + .stroke(.white100, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 4) + + if isDownloading { + // 다운로드 중이면 프로그레스 표시 + DownloadProgressRing(progress: progress) + .frame(width: 25, height: 25) + } else { + Image(.polygon) + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .offset(x: 1) + } + } + } + .disabled(isDownloading) + } +} + +// MARK: - Download Progress Ring + +/// 원형 프로그레스 뷰 +struct DownloadProgressRing: View { + let progress: Double + + var body: some View { + ZStack { + Circle() + .stroke(Color.gray300, lineWidth: 2) + + Circle() + .trim(from: 0, to: progress) + .stroke(.white100, lineWidth: 2) + .rotationEffect(.degrees(-90)) + } + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopSkeletonBox.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopSkeletonBox.swift new file mode 100644 index 000000000..53c17c35f --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopSkeletonBox.swift @@ -0,0 +1,47 @@ +// +// WorkshopSkeletonBox.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI + +// MARK: - Skeleton Loading View + +struct WorkshopSkeletonBox: View { + let width: CGFloat + let height: CGFloat + + @State private var isAnimating = false + + var body: some View { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray50) + .frame(width: width, height: height) + .overlay( + RoundedRectangle(cornerRadius: 10) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color.clear, + Color.white.opacity(0.5), + Color.clear + ]), + startPoint: .leading, + endPoint: .trailing + ) + ) + .offset(x: isAnimating ? width : -width) + .animation( + Animation.linear(duration: 1.5) + .repeatForever(autoreverses: false), + value: isAnimating + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .onAppear { + isAnimating = true + } + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopSortSheet.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopSortSheet.swift new file mode 100644 index 000000000..7c5f2d8d6 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopSortSheet.swift @@ -0,0 +1,80 @@ +// +// WorkshopSortSheet.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI + +// MARK: - Sort Option + +/// 정렬 옵션 행 +struct SortOption: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Text(title) + .typography(.suit16M) + .foregroundColor(.black100) + + Spacer() + } + .padding() + } + } +} + +// MARK: - Sort Sheet + +/// 정렬 선택 시트 +struct WorkshopSortSheet: View { + @Binding var showSheet: Bool + @Binding var sortOrder: String + + var body: some View { + VStack(spacing: 0) { + // 헤더 + HStack { + Button { + showSheet = false + } label: { + Image(.dismissGray600) + .resizable() + .frame(width: 24, height: 24) + } + + Spacer() + + Text("정렬 기준") + .typography(.suit15B25) + + Spacer() + + Color.clear + .frame(width: 24) + } + .padding() + + // 정렬 옵션 + VStack(spacing: 0) { + ForEach(["최신순", "인기순"], id: \.self) { sort in + SortOption( + title: sort, + isSelected: sortOrder == sort + ) { + sortOrder = sort + showSheet = false + } + } + } + + Spacer() + } + .presentationDetents([.height(200)]) + } +} From dadf46ef3e202699fd5535b904258fc361ff4f66 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 22 Jan 2026 22:53:28 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20Workshop=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B0=B8=EC=A1=B0=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 32 ++++++++++++++++--- .../Keyring/WorkshopRecentTemplate.swift | 2 +- .../Views/Main/WorkshopItemDetailView.swift | 2 +- .../Views/Main/WorkshopTemplatesView.swift | 2 +- .../Views/Main/WorkshopView+MainContent.swift | 4 +-- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index cce5b39b4..6c29e4b71 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -119,7 +119,6 @@ 4C4733232F1FA2AB005D2376 /* WorkshopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47331E2F1FA2AB005D2376 /* WorkshopView.swift */; }; 4C4733272F1FA2AB005D2376 /* WorkshopBundleBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47331A2F1FA2AB005D2376 /* WorkshopBundleBanner.swift */; }; 4C4733282F1FA2AB005D2376 /* WorkshopTemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47331C2F1FA2AB005D2376 /* WorkshopTemplatesView.swift */; }; - 4C47332C2F1FA2AB005D2376 /* WorkshopComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733142F1FA2AB005D2376 /* WorkshopComponents.swift */; }; 4C47332D2F1FA2AB005D2376 /* WorkshopViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733162F1FA2AB005D2376 /* WorkshopViewModel.swift */; }; 4C4733942F1FA388005D2376 /* PixelDrawView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733772F1FA388005D2376 /* PixelDrawView.swift */; }; 4C4733952F1FA388005D2376 /* ClearSketchDrawingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733652F1FA388005D2376 /* ClearSketchDrawingView.swift */; }; @@ -200,6 +199,13 @@ 4C4733F32F225781005D2376 /* WorkshopMyItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733F22F225781005D2376 /* WorkshopMyItemsView.swift */; }; 4C4733F52F225785005D2376 /* WorkshopGridBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733F42F225785005D2376 /* WorkshopGridBuilder.swift */; }; 4C4733F72F225A2C005D2376 /* WorkshopItemDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733F62F225A2C005D2376 /* WorkshopItemDetailView.swift */; }; + 4C4733F92F226143005D2376 /* TemplateActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733F82F226143005D2376 /* TemplateActionButton.swift */; }; + 4C4734002F22615D005D2376 /* WorkshopItemCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733FD2F22615D005D2376 /* WorkshopItemCard.swift */; }; + 4C4734012F22615D005D2376 /* WorkshopFilterBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733FA2F22615D005D2376 /* WorkshopFilterBar.swift */; }; + 4C4734022F22615D005D2376 /* WorkshopFilterChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733FB2F22615D005D2376 /* WorkshopFilterChip.swift */; }; + 4C4734032F22615D005D2376 /* WorkshopItemActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733FC2F22615D005D2376 /* WorkshopItemActionButton.swift */; }; + 4C4734042F22615D005D2376 /* WorkshopSkeletonBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733FE2F22615D005D2376 /* WorkshopSkeletonBox.swift */; }; + 4C4734052F22615D005D2376 /* WorkshopSortSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733FF2F22615D005D2376 /* WorkshopSortSheet.swift */; }; 4C65303B2EBA5FA0000F8154 /* CheckmarkAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C65303A2EBA5FA0000F8154 /* CheckmarkAlert.swift */; }; 4C65303E2EBA6042000F8154 /* ImageSaveAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C65303D2EBA6042000F8154 /* ImageSaveAlert.swift */; }; 4C6530442EBA8077000F8154 /* PurchaseSuccessAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6530432EBA8077000F8154 /* PurchaseSuccessAlert.swift */; }; @@ -548,7 +554,6 @@ 4C3688062EC07C4E00C64E75 /* NotoSansKR-Thin.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSansKR-Thin.ttf"; sourceTree = ""; }; 4C3688112EC08A3100C64E75 /* HancomMalangMalang-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "HancomMalangMalang-Bold.otf"; sourceTree = ""; }; 4C3688122EC08A3100C64E75 /* HancomMalangMalang-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "HancomMalangMalang-Regular.otf"; sourceTree = ""; }; - 4C4733142F1FA2AB005D2376 /* WorkshopComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopComponents.swift; sourceTree = ""; }; 4C4733162F1FA2AB005D2376 /* WorkshopViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopViewModel.swift; sourceTree = ""; }; 4C47331A2F1FA2AB005D2376 /* WorkshopBundleBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopBundleBanner.swift; sourceTree = ""; }; 4C47331C2F1FA2AB005D2376 /* WorkshopTemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopTemplatesView.swift; sourceTree = ""; }; @@ -632,6 +637,13 @@ 4C4733F22F225781005D2376 /* WorkshopMyItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopMyItemsView.swift; sourceTree = ""; }; 4C4733F42F225785005D2376 /* WorkshopGridBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopGridBuilder.swift; sourceTree = ""; }; 4C4733F62F225A2C005D2376 /* WorkshopItemDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopItemDetailView.swift; sourceTree = ""; }; + 4C4733F82F226143005D2376 /* TemplateActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateActionButton.swift; sourceTree = ""; }; + 4C4733FA2F22615D005D2376 /* WorkshopFilterBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopFilterBar.swift; sourceTree = ""; }; + 4C4733FB2F22615D005D2376 /* WorkshopFilterChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopFilterChip.swift; sourceTree = ""; }; + 4C4733FC2F22615D005D2376 /* WorkshopItemActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopItemActionButton.swift; sourceTree = ""; }; + 4C4733FD2F22615D005D2376 /* WorkshopItemCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopItemCard.swift; sourceTree = ""; }; + 4C4733FE2F22615D005D2376 /* WorkshopSkeletonBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopSkeletonBox.swift; sourceTree = ""; }; + 4C4733FF2F22615D005D2376 /* WorkshopSortSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopSortSheet.swift; sourceTree = ""; }; 4C65303A2EBA5FA0000F8154 /* CheckmarkAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckmarkAlert.swift; sourceTree = ""; }; 4C65303D2EBA6042000F8154 /* ImageSaveAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaveAlert.swift; sourceTree = ""; }; 4C6530432EBA8077000F8154 /* PurchaseSuccessAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseSuccessAlert.swift; sourceTree = ""; }; @@ -1083,8 +1095,13 @@ 4C4733152F1FA2AB005D2376 /* Components */ = { isa = PBXGroup; children = ( + 4C4733FB2F22615D005D2376 /* WorkshopFilterChip.swift */, + 4C4733FE2F22615D005D2376 /* WorkshopSkeletonBox.swift */, + 4C4733FF2F22615D005D2376 /* WorkshopSortSheet.swift */, + 4C4733FA2F22615D005D2376 /* WorkshopFilterBar.swift */, + 4C4733FD2F22615D005D2376 /* WorkshopItemCard.swift */, + 4C4733FC2F22615D005D2376 /* WorkshopItemActionButton.swift */, 4C4733F42F225785005D2376 /* WorkshopGridBuilder.swift */, - 4C4733142F1FA2AB005D2376 /* WorkshopComponents.swift */, ); path = Components; sourceTree = ""; @@ -1146,6 +1163,7 @@ isa = PBXGroup; children = ( 4C4733352F1FA388005D2376 /* Recording */, + 4C4733F82F226143005D2376 /* TemplateActionButton.swift */, 4C4733362F1FA388005D2376 /* CameraPicker.swift */, 4C4733372F1FA388005D2376 /* CinematicAppearanceModifier.swift */, 4C4733382F1FA388005D2376 /* PreviewGuiding.swift */, @@ -2578,6 +2596,12 @@ C6B571062EC2337C0049F969 /* MultiKeyringCaptureScene+Capture.swift in Sources */, 3822DDC22EBBC712003125BE /* KeyringEditView.swift in Sources */, 4CF2A9662F0B8C5800BA9FDA /* PullToRefreshIndicator.swift in Sources */, + 4C4734002F22615D005D2376 /* WorkshopItemCard.swift in Sources */, + 4C4734012F22615D005D2376 /* WorkshopFilterBar.swift in Sources */, + 4C4734022F22615D005D2376 /* WorkshopFilterChip.swift in Sources */, + 4C4734032F22615D005D2376 /* WorkshopItemActionButton.swift in Sources */, + 4C4734042F22615D005D2376 /* WorkshopSkeletonBox.swift in Sources */, + 4C4734052F22615D005D2376 /* WorkshopSortSheet.swift in Sources */, 4CC3D36E2EC2801F0009D376 /* WelcomeKeyringViewModel.swift in Sources */, 4CC3D36F2EC2801F0009D376 /* IntroViewModel+WelcomeKeyring.swift in Sources */, 4C4733F52F225785005D2376 /* WorkshopGridBuilder.swift in Sources */, @@ -2585,10 +2609,10 @@ 4CEBB1722EFE66CF00CF53E2 /* ToastManager.swift in Sources */, C6B56F232EC0341B0049F969 /* KeyringImageCache.swift in Sources */, AA6298522EC233D2001576C0 /* BundleEditView.swift in Sources */, + 4C4733F92F226143005D2376 /* TemplateActionButton.swift in Sources */, 4C4733232F1FA2AB005D2376 /* WorkshopView.swift in Sources */, 4C4733272F1FA2AB005D2376 /* WorkshopBundleBanner.swift in Sources */, 4C4733282F1FA2AB005D2376 /* WorkshopTemplatesView.swift in Sources */, - 4C47332C2F1FA2AB005D2376 /* WorkshopComponents.swift in Sources */, 4C47332D2F1FA2AB005D2376 /* WorkshopViewModel.swift in Sources */, 386B17642ECD142600CCCC23 /* String+Extension.swift in Sources */, 4CEBB1652EFBA54200CF53E2 /* RootViewModel.swift in Sources */, diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift index d8c10a4d7..0a6353541 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift @@ -39,7 +39,7 @@ struct WorkshopRecentTemplate: View { private var loadingView: some View { HStack(spacing: 12) { ForEach(0..<3, id: \.self) { _ in - SkeletonBox(width: 112, height: 112) + WorkshopSkeletonBox(width: 112, height: 112) } } .padding(.horizontal, 20) diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift index 101b4e4b1..179bc80e1 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift @@ -270,7 +270,7 @@ extension WorkshopItemDetailView { .frame(width: 38, height: 38) if isDownloading { - CircularProgressView(progress: progress) + DownloadProgressRing(progress: progress) .frame(width: 20, height: 20) } else { Image(.whitePolygon) diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTemplatesView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTemplatesView.swift index d8e491c1a..4c758b6bf 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTemplatesView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTemplatesView.swift @@ -97,7 +97,7 @@ struct WorkshopTemplatesView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(TemplateFilterType.allCases, id: \.self) { filter in - FilterChip( + WorkshopFilterChip( title: filter.rawValue, isSelected: viewModel.selectedTemplateFilter == filter ) { diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift index 51cfd2833..af2f54616 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift @@ -29,8 +29,8 @@ extension WorkshopView { /// 로딩 뷰 (스켈레톤 애니메이션) var loadingView: some View { HStack(spacing: 11){ - SkeletonBox(width: twoGridCellWidth, height: twoGridCellHeight) - SkeletonBox(width: twoGridCellWidth, height: twoGridCellHeight) + WorkshopSkeletonBox(width: twoGridCellWidth, height: twoGridCellHeight) + WorkshopSkeletonBox(width: twoGridCellWidth, height: twoGridCellHeight) } .padding(.horizontal, 16) .padding(.vertical, 92) From f8109bb067efb4ce91bec5725ae0b4dd92c240f3 Mon Sep 17 00:00:00 2001 From: giljihun Date: Thu, 22 Jan 2026 23:50:05 +0900 Subject: [PATCH 5/6] =?UTF-8?q?style:=20=ED=82=A4=EB=A7=81=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20=EC=97=90=EC=85=8B=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bundleMenuIcon.pdf - keyringMenuIcon.pdf - makingIcon.pdf --- .../bundleMenuIcon.imageset/Contents.json | 12 ++++++++++++ .../bundleMenuIcon.imageset/bundleMenuIcon.pdf | Bin 0 -> 7120 bytes .../keyringMenuIcon.imageset/Contents.json | 12 ++++++++++++ .../keyringMenuIcon.imageset/keyringMenuIcon.pdf | Bin 0 -> 5835 bytes .../makingIcon.imageset/Contents.json | 12 ++++++++++++ .../makingIcon.imageset/makingIcon.pdf | Bin 0 -> 4074 bytes 6 files changed, 36 insertions(+) create mode 100644 Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleMenuIcon.imageset/Contents.json create mode 100644 Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleMenuIcon.imageset/bundleMenuIcon.pdf create mode 100644 Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/keyringMenuIcon.imageset/Contents.json create mode 100644 Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/keyringMenuIcon.imageset/keyringMenuIcon.pdf create mode 100644 Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/makingIcon.imageset/Contents.json create mode 100644 Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/makingIcon.imageset/makingIcon.pdf diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleMenuIcon.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleMenuIcon.imageset/Contents.json new file mode 100644 index 000000000..36edb57e0 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleMenuIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bundleMenuIcon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleMenuIcon.imageset/bundleMenuIcon.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleMenuIcon.imageset/bundleMenuIcon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cc681492f7fdad211586e1bb968c71430f34f244 GIT binary patch literal 7120 zcmc&(c|4R||3*Y85-Lk3vM*yW7$m#wLu22U!C1yNm@L^t_GK*D*R08!UD<|+i0sNk zA(16J`3*hwKE3bz`TY9tz5kf|%yq7FzUO?ed+yKon!^f}Q{V;hiIB4T<1SJVfFIyw zVMhu8NJs#H(hexNE7BYV2LR>Z9tcagmZA)<3wHq(5T|{(y92IU4sPjW1^>H86^^t) z*#ZQ>!Xkeo>HtApOHz{54dn_qcO*rp*CrvLS0-q&PM$kay!iO~?X3^hlJKe?@@5D6 zJLFQuz3^F%~2qd;=fSE8bi=(#G+F`+)I zjh5IACslSf+@Se!G z!!u~h$@5xOQ^a&C_Tu3s6s(hehn7me7!h!=EMCyhtxGN3=fg)}Q6z*)Hcpi&885zGyD8S?p# zxE`@TO&HvCnaxId?=GXJa5AI5g}x-GOj3v1m0r>7=EO(>;@+F100+tUC+Q^Dd+;2~ z${bHRGEeM#2dpx`#Sj3p$fUM-pUNddm}L8q*QDgsBhQcc)4n;^?T_!^f6n>*+6(%< zASp^x=9hLah%Nw#!lY=&@#+F4pm;m}$Jq=F_~7hILjHo;L^VMXA4u!*kwN(U0k_vgc#3Tb%>l)a{`+ARv;BgOXEIT)26m27mHAjZ_yD0wWrg zz9YMs9q|TSMA1kQC3WSE=^OTIL<#0}Wf$n%llZUf_@Da#Mbj`)Jn2~ZV1yPiI zlW9m#h(=9O@Xdhjoo}QBIP@aLJe}V`m5}X5 zLP&2%qJq$V(2@3UnHzgqUoC{BuRmtiyu@r*f$vW9vrzHkxK`dNjWnU zQlW!ugPCP6=0rJ1@GGR;=1{A!|9M6a5IIElAH8+SWu`Rz= zl#;7ggx1=4{8v4ddM~usF#JhkDN1!Yewc$sel)MD$mp}M-NkJI_sE9@*N4Vy%KAzZIuKQeVS~(ma}|uJz@n&KM@ffM3z8>Lw227#L=Z!CMASqp#It0&ZsML( z(!^y7Hm%ikm)cICGk$HztK?W=5HZQ(Uej27;JVGT-SIhjl>O>3tr&2Duiq4HJ$LS+ zUI&at&$6W(6qQEz_OZd^*2jp)#9$yo-LBZS!JgF)W9x3KW_z#JxRz*=b@FPB=^M=Q zjc-<7*IUvg3-WU2pRf!S4=<0V4uOY_bJFsriVp9_6k$gq-f|DE45tl`y)!LolWsFP z3BZ5pkBBvMevys+(2^5pUO#uKgSNwkMVqB=NG#7QFSMK5Qe+-AZ#Un>rgx=KDZck+ z!c^h2;tWI}B4n9YaJ+w3)y$)(Y>>fn&6ZE7LYT=S_WF%TRfXG@oibe(;quY)5nbFW zzI_~hElIbNOv}HN#h2TaJuJtR9hbY6O_iNgfokU_VdK-ra{3F^wp+RHeYR3{Xz|qySmpFA%lVCGm`f4rn#FL6bid3eZNm##!K~s1A4R=iAa=puR$xLSBO2gK){Y;j3 z3i_5;`X>4#;v?s#!=}%mtA(SLyH`%LPs?VDR|}?D7oF^VXJ=?Te7ATvb+dNM`0Lbi z=my#P)QU*wJ1e&d_b;4xbTXYd#NWBoV%pQxxNhb7hUh9n;VXP$qX69|I0!+8S$ zv5fV=bm~M0BndB1#dO2f;O1@~1>6BKe zoA^G6I-(nRTn$P|Sx-p-WkCYc?FLXh;&lQZkNW<)@u790VM`f(#pmh+(8>LD6(g5v zbhFA)>s~nF^WfoNC43bqA39?x8GUQRSIca8BY$FHeW9D$M_Kh1BqyFee}xVIGqX7dMD7GjxTHVGcEEv@}awCyCl2hYwlCtxq6joPP-Koe&r)| z-+J)>!tcdLOvG2c3pya)WjHd*gi1o^^oR6{H5*Ddk8jTAeD7EbrH{6~!6~}AT=Ze7 zh3kFEC%cZ%!T$ypdk%)37`2hYBkQ93j$1Gl znTQ&2RqfaGlZL0li*<#jybo^AK7DKAVzTd}NnFYD+;XSAk?d^-A|v7IJTTg$e3`D5 z+GQEJySX;oho4+wveDpLjLlnY*G?{79ZP?j(KOIJ>)P>MVN0zkO|5O>_}z&8`zfEk zIh_?p?3AN##G%;1%mHwAqtWdn-{D$R0Q{V95cm177v(9GzHY2Vz1L_xIgtJolsd;t z0ixJq;*{X>E!(I+2=7f$C~o<76gJlL`pHd=P{oEkpx@x$@s{42;H&E4#mT{yZpZE^ zb{{rmo8G>~7x-jfYH#x$>XRZL_|fO&J2NNE5E>A8$KadSPDQ(jol|sMt?v|=QRsNj z_*GNt?mp9qWcpsu(fAhU(c7)mvcdX6xT$Jujqk<117~5l$BsCK#mgP*q1AH6)z$05 zhr*ZG5JK?Bz^4H?_C9v z{*XoFr|WsG!OFxQ`&`csjMtCAgL(@PZn%MToH>r0ApC**EF9xYd=HrgZ z?h{l3wC`ReS@xrUd(yhqwqls|zPU0bi#Kyc5bRifmof`bSRG=~dbcOey;CVuoH4Ep z8wrck11N!>u&57T@{g>S7j{#Tw(4ZD%$K`575G(to|TV3VZmRpApoc(D=TB}2Dbv7)~Z?ndcCTK>Z*yxI+%)n@1{W6nV zwSCs%+pLxOK|k-qUcawxt;Z|nl6~L36W>dI|F*xqKjo7o`A&@K_`b1y=IX}xEyMeD z6VWR*TQN*+ttXp0*e!?JicQ-=gBanb*g>aSNcPi4h6yjkD*7l4^Kq$Q1nXtsm$Zfd z+0g_pIbhS!y>7TV=<-^)d){lHijTOh4S$Lw)O~D=u0X@kSaaarL0-}m?pMtOX^u(D ziJR$*MwU)Ujij}Ewg zzeMR2`Hp}9wu{wSyy5O$m~OMI5;g|uc3*!_SDh%;-56a7sAZf>@XV3yrWT1=w&6?~ zTPzdo&*ZZhl!@{JPovRO-%iX;y>G~Ly}aB+75bZ1iof9mJG8sGx zBO11+AX7|ZsJu_SmWOu|~$$lzBua zJQJ}pLqPvMd=dh}x~D_J?|^XT*oh6%Sa8gqsE(=P(Jfjm8=H9F=mdXOyXoE|kYZ^w zd8%Nh|9xVhQS5d5CSU2L0$X&j*hga@?Xj0q#h~U-2C+27BZ+)+8&g=x5ss^0z69my zWE?ktW__(Z7iBgGCaRa>q7waZT#oe(T>e%aVR+4m-Gqtf+3jJ6;)}`#8&3Kod+AqDTIAr=h0P|i+ijX zp`BMAXx7AFaO-P1z2+qIlQQbf;%x6VXT&j{_Q#?LiI6KkYw`ABR%Q092HNpG{!eEW ziyI!z2bR2uep4x1pR3;0C`;&-PmWda;x9{nMzJ@&nQu>7Z3vyL!&-pXvmIN`4Vc*@^*<*4#=`_IFW~4Ny zxuPBbrDMV_0acC2XGg0eyRJbOb_f}Jc+5OJ6hp6==`OF4m1Q&)rppAFQ*Km~2Hvc8 zl)E0|s|8Eludn2Mo@>zx4WeO*%^kzoDY2B8;8}br(C>Pue_IFmLYsn8+z@#GNlu^+ zf=Xwo{hTx*k$FB+Z>mLrOLV7v-9^XMj8 z8fP4L#BwfDcUQ#z)f*gB4t%C!ok~5be&uZlb4uxQeB4G`6m!;_!?f;-IgS>QC9C3G z6JN+0+0I9k_aY?%uS)l*Hs?Et(kl@QDogw?laT?@m4-=Qv?!NiCrY+IN(FUdL(+42 zGBmW~5}4(NqIX||!c*25cpJ5wA`U$H6~s8Q*@$K-jaF5>MNvY$!hGOoMzY~94)!+k zwj-Tvvy$3oYLW_(cc}Il%V&b+KsEt0LF8-(#tCz;!iS%>bCV|rfn#m)KqG+El@@uj z%^XqJF}_-wnMUnTJ>NIf_Y2n6UoNR}u2Y7{jy6lEp;?T(R$x7qa;qsN?7EKhw=8^a zuH8_6UdjIc0bLX0rK1@#X4Ct*X$k~@*s4fvTNB1BqcfpYQrFBp zrHsR&a{GtSia>H(yOh;~l^m0(6pwPlJQa_JC|-V&=v?d3+cnCHw}tCAGwiIXb+*V2 z^ybYxU8zb)idaO0h?_$MB^juF_710jPJXO84-Qm(kLGn{%aqqN@OLZPPGpq!d*8Qg zZLwfR%463yw=b-6!U`2z_6Xx1GK02!7=FIhM`HkNB*nqA$HnH<{W^F9EnqYM4_dm^hIyiFDKuC zi!&+ABfE%c-q#H1dnW2jZ+#GxZG86 z_J^+|N1)5v2xHg5t>Sz%Df`|utC)_^H7k*jYY}D2tOYb^>r;Ld>w{_NBMvl)|G}E{WQ}04g;miW^3m>yc$(_nsl6eLq z77Xl1;F`OswZ3pByP|I;HGB<=`!KDu7b6uTHywFZnoOj;4lX045`s8BCzOkGC6DoI znk5xz24aA^r&KA;+@ZDRcb@Y>?kS*;+*bhIwflBu{VoaB*g~Uy$?+kEXeJenRbLH*=|$5`ri* z700LMz$9+};}=|_0_olOFOKoFuK#ghApn7Zf4KvZU+~#E1`_FnaswC||9v0TZ9|=#Co;`j7Ry;gEmK1o>?y z9R%DH?h18$cxKL&FkaF*k@zF$U8)f{O9;DG<^JxjOSukD|v>4yxm<|uOq zC!60lg2pYHlpg@%C;g$unPV3Qg9X6=>x;iI5H7!GJ;A+ls?Y}t(&W|&C!CHoq(4F;oO7=y8eD1>a;_gx~9WUq`Yk*$<{ zDJm(lFOmO@e)WBQ-*;XA&UMYaXYS`Y_j}HBzjK}YJ%Wa68WLbhS%6?Du>rt9DImtd z4FCixC;&mqXdDvjfxsbwAT^{f$`NU-sX`198%P61iV?49Vpt97h;c&x6VXL_xZqrY z5GYLcA4DH0P3$Qu0=#ipB*Gnl&wiAOGGrcO?ZWtN#YvEpTei2|)Jr9+y(y7@JrrGd zDv1nnobl9C8z!SLCUrGRYb9!RE>E0=6C;z+BsCS4sr7X_zzaFKTb!cR6lZat^+xVp zuAY8U(Hs)|IeX$`?)QR2vIGwbH7RTf?eju=JA(iB)%HuuZaIXdq)a(pZFu^Z; z9plGd+Z;2Q(MA9LB+j&hbBpzqW$U5g_f{7CKAmK3He36o#r%8PJf(D2&NF=~4cuf3 z@E`K5!CfsZJPc&*W&$BD`_>X*E%PjKZ6;t}vKJsPIm{E*zE`7-a)=N^dJP5rKyk$E zPEtlRGo5p}w4=ps1WV(#bg)zuQAs7}G55%EAZR>}(e&_+0MUvwhuO3o-DK{SmF|ax z+{3Fq{Z6?X@yCF9CzRGD?yKE_pH=PkU{_MpkEMPc$~s8V8A^^0rSPO)e#*HMuEYr7 zd*=3(>Nt=pN{Mxptp2)!A=y^wK|U83IW+&IOsI4|Rb6<@ETDnhBb;1{uGpTgh0gra)1^>Bv(Bu!1$Cl5#gwiVFIX@TTyj!gEGvhb&}H z>uE|4_Pbu(0LZZA+@6k*R7)!|$j=&@`Xv8}X@g}??B?~Mr|F1nDYP!-IiBPjJ%l?B zZeYFi-gNRl_7B{wF=o%sAh;h~8`VPd`Ch1J45Xci`r5ID(5!hS$E8p2a?|@6)jK&k zuq1;YqaVPJ{B1lZA^2(Xw{bjY3xgs6)bVjvXH{sGd*|A|_dcC+n7xuGALS{0#G?p4 z75aiW&lo9Th7NpQVl}a&N-erUT0yFOi8$@s#R~%Ds1tW%ahbRj8I?UP zbH1!3%X!z@h?cCN>`Co5gKMeUuFk`H&AOqz zo4u_`LfxF*f!)=qc0zWdA?80)_lu7yKQjy~d1^FTY*^r_kI+I`C04jrv6rV8T9o6B zS2N!=oYL+QEj>PXP6uPCd4Q9W(mj8Xq}oB`Bxz;kozS| z?J;w>F8oTP%5F=oDOHI>dAqr`xri~m2vWX=3j1&@o@$?}nN>#olifF4pMr{JQ9G#J zBO@QB&EzvSFRw^wyH{JqjPv`{HQn9AZi;UbKBtWcu?@4H2hB_N+2Nh%DCjK+ru-I; zt)1YwtTV4Ntuos(QJFMQ5K7sKy1y+3M^DQxC8e!Cb1H)We%RQTpI+ z4(dAU#-fDuXy3H1y>EBr0GH#ktE5ad?5slq2T!c7#s$X?mFEu8>bKQno{Q-O_X_v6 zrd~+3tNK!zROMEAvnr+Xpvt>)qVlj7{Ah06bac{2&2s*s>w4iE^=kK*=-v8A_mwfD zuc_l-A1u{=cUZ9p)nXpxc@FwTd6nbX9fs4D`&2%dX~q_ny?5^N?TXjm(swA6)(<%^ zot>O*a6TRcv+A?6eu#gq{`SUu;{`sSlIG3^9-ow^#Rjj*imBYDrN;Hit5bP_>3D&F z>^1xXYC&WTy%scpb#Qm`2`Fd@YF%p$XiaIkj86-^xHYsHy?u8(W9`wp&C0}LY77m?EDKO$c2JEz{9Wi%PANxRJI8ntHr32MI^EBPGIs3ET~! zZ$=45h5>`sy*0hzsh7n=Skc@qybVGTXKK&QJ9Rf34h$9!&YkWQ6A)`^pC=R&KDSRt z2Hs$c)ZrKBhiObknZ~oI)oaWZwimh;m0k|0o_vB8AD2823h+!Bs`z5|*8EEF_m!FlkoY`1=#xO_dt_g3#ap4qnf#`Q~h<{-9f;6wjx9c!;ie2dP0 z+fFp)YIeKs}E4%j-PN(|7u7Izk2YXj7j@w+# zt6sO!Z+kTsNn{L@>BCyb+25+-vpu z@^|DtZruj zL2DnaWFI!(hkdLswUfAZVfy|nTQA$)AS0R@{>P46?M)|M<)Cts+1`V0`&KQUX=CwP z^w?fop6(@2tF~Qj#NO>H`q*xkcJJ%k?E5**{Vmg2!Visgz2+>vCu0Y1USFM=2Y~nyIeK{@+Nf8__M!iC>&Y-=rx*RuS&~~_|{2@GJPJ#i< zuzprVK{U8>wP7#1Ct2fe>&AZ6+wPZTyath)jYXi4ft`bOi)HB-4~IXF4=i=McTNZe zo%48NvFq>!Io_Mm({hpJusjI4|2ggA)L{$!G#I*NwGpsY-7f2fx&7o(@C1}w=3vL> zMRUgX?%CIh**o3)qw6C3uhugv2O0*DcDikK!Sp+Oo-m~EmOO*Qvn}VLuT|V%zjDCn zt~Rk#DcSD*`OF+KxRl^U@csE zHjgUgvLAb+lkL^6#};l-vOW}FmD}Yrw)F{+`j%cFeTaLV^7lx$Hp=$#>PP1;)Vn=+@&8> zakNAH0HlZ0KaP;tBe_lhEG}bAV^zPRR!UVbOMsRsxi=pL~}G)=64p! zyQ<@HnkG1scRl(gdvtL`0Hq`$Qlv)mqeoWu&o7X=++SzH{yG!#*O}12&V>JUrp#Yw zO8<2xSnBUn|0eU78X#2+8iO_QL^%EwP8AF!sc-yKE@IX%wV{8g{r|p>z(^#2zJ-u~ zP1UzeQf4z_vHgwM;-2ZC=v2p?+=Yt zmtw!<$ZE*H8kP;SebY#{;pQ-!3xmI%;QDaAR0wo--Fl+gt{~-QrPoOR26X?!H>=RdZyHJ<>u7ZBHUs9^e1_I7|>(E6!K|)cM z#b8M^Il_DoBGB2F)(=g=FFfzJX zpx`&Gh|Qu%LzdPq~p@2{MJd>)VTpgFu=jys2VeUlPbX#ylFlR&{0%c zW+%`@%-U?svfw-!ojU4E0u7uRp|^GdR>J0*0G#t%r)2tpK*kyO_IAEOUhqQ=^J2Z40gcKpZ`)DJXt+3V*`;*$wPD_2zV7Rmr7^}$FbC%t9GTk#k zOpc*;k%c-d3B@7{d$xtHlV8l9*98w>OSac8gWNZ6;V(Yf3vAl%aBWbJoUyY4HqR8b z)gpm|U=F4xzmH=5Fmv%;ZSpS0VfsLo%DFwCZ&L{dlL|EklY?R#Cm-r5*u%@(^94t4 z=}u2$4VrAYtE|{G9!kx;elEb7`+z)CS#~Xl2XK?6b%4?_iO%^}MZQnznU!vyD`51y<57MkgvL7g}4zh2>Md z?{HR52n7=mOq!|8HTl_e$y6Qt%Q-b_Pv)I893t218y5stI8?umSLL9(8O|#Ai&W|DMavwWS z5-1rjOlH3=lpbklDzL9rZF~{o>>U*vcK|kCke`b`p_KI+e79ahD&6)fVsb&04VdRa zC1B@yX;WNyj%LvLnuGXjx(wNS=pxg`=RNn`&}K@$9LSr|li`8|Pw{Dw_=G!4<7qGQ zv+r7$j6$j5Wr?9CwJ-YY4{pJ&n0CoJQiXWHy11BAiDoXWt5UNU99NFXHs5jOXst`{ zkwn#A$*JYz=u=`jZgcLFtq^v>?s-w>-oe`Dnpo4PUeLkLd(Mf3AZtO!rI_d-GxdS9 z@fg#!ke(vn0|NZVhk>s6Bu#PX{o&h!@wC~{7n9=tYBUG~)}U-rV=TNv4Kqfb$>}`yH-vl3pJ$>-&;z~5 z$PkQ|qvGbG6!8}@GFhYL%qNlDowQZsLH=D$qTUGycWAz`TI3()RsP&KteI)hAY`W6 zGS1<2bJf#KQR>EeMFLIXxM;9I;C5Pm;&Ym@(dMs#^n_teH`P+VMgEFglwnL(i$%kl zhZlo47#eI6>i>sXB5i3u&+5RkV5#35qwH_^=m^il1B3GhUa|S-EY8&5lX#kEh;aFJ zT&IV0LLpQzc;FSHH9B%eQvd2epueDh^?orKBfT*`SVyEckYv0_dn>UKw{H&|(t~(M z(*FDlG(=#D7*OsX=-+m667;Y0MjezBajHPl3P{H87o!hxEcn0X?@dJhjS2n-lQ|0M zhr}9Uk?TJSCt6>~XQ8+Xbh{O6IfBQg?wudw3D9sRyaq@8_o^A?j zpj_M$B0q0^G@GL}{v0ffa>RK25&P|7bP*mdKw;#s=ux@7e=na@rk^sXB5(*a#^uk2 z;E9t4NCAn8{#4`0tiqsBX(-T{{x=3D*7qpl@e6~sz39jAy8t` z|HPnDP@=>BPfV65_J3dyng1Ru4f(gRI4lB%Mq&XZ3AELS0t`SCVoWz s8#odlWQ@V!fFxm$9wd+nDga5U3$f1LI0P1VR4FJF0s#mLsvBthA9q@&MgRZ+ literal 0 HcmV?d00001 diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/makingIcon.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/makingIcon.imageset/Contents.json new file mode 100644 index 000000000..609ac8f6c --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/makingIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "makingIcon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/makingIcon.imageset/makingIcon.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/makingIcon.imageset/makingIcon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..85b66948086525f9d97aa00c7b93e0788468c52b GIT binary patch literal 4074 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 qg#b3i;fNr5l6Q*=d>-S2rpJXAX95w0C+ Date: Thu, 22 Jan 2026 23:50:43 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EA=B3=B5=EB=B0=A9=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkshopMakeMenu.swift 생성 (glassEffect 스타일 드롭다운) - WorkshopView에 메뉴 상태 관리 추가 (showMakeMenu, makeMenuPosition) - makeBtn을 SwiftUI Menu에서 커스텀 메뉴로 변경 - GeometryReader로 버튼 위치 캡처하여 메뉴 위치 계산 - 땡큐리엘! --- Keychy/Keychy.xcodeproj/project.pbxproj | 4 + .../Views/Components/WorkshopMakeMenu.swift | 74 +++++++++++++++++++ .../Views/Main/WorkshopView+TopSection.swift | 38 +++++++++- .../Workshop/Views/Main/WorkshopView.swift | 33 +++++++++ 4 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopMakeMenu.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 6c29e4b71..1b6744b6c 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -206,6 +206,7 @@ 4C4734032F22615D005D2376 /* WorkshopItemActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733FC2F22615D005D2376 /* WorkshopItemActionButton.swift */; }; 4C4734042F22615D005D2376 /* WorkshopSkeletonBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733FE2F22615D005D2376 /* WorkshopSkeletonBox.swift */; }; 4C4734052F22615D005D2376 /* WorkshopSortSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733FF2F22615D005D2376 /* WorkshopSortSheet.swift */; }; + 4C4734072F226B81005D2376 /* WorkshopMakeMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4734062F226B81005D2376 /* WorkshopMakeMenu.swift */; }; 4C65303B2EBA5FA0000F8154 /* CheckmarkAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C65303A2EBA5FA0000F8154 /* CheckmarkAlert.swift */; }; 4C65303E2EBA6042000F8154 /* ImageSaveAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C65303D2EBA6042000F8154 /* ImageSaveAlert.swift */; }; 4C6530442EBA8077000F8154 /* PurchaseSuccessAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6530432EBA8077000F8154 /* PurchaseSuccessAlert.swift */; }; @@ -644,6 +645,7 @@ 4C4733FD2F22615D005D2376 /* WorkshopItemCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopItemCard.swift; sourceTree = ""; }; 4C4733FE2F22615D005D2376 /* WorkshopSkeletonBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopSkeletonBox.swift; sourceTree = ""; }; 4C4733FF2F22615D005D2376 /* WorkshopSortSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopSortSheet.swift; sourceTree = ""; }; + 4C4734062F226B81005D2376 /* WorkshopMakeMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopMakeMenu.swift; sourceTree = ""; }; 4C65303A2EBA5FA0000F8154 /* CheckmarkAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckmarkAlert.swift; sourceTree = ""; }; 4C65303D2EBA6042000F8154 /* ImageSaveAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSaveAlert.swift; sourceTree = ""; }; 4C6530432EBA8077000F8154 /* PurchaseSuccessAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseSuccessAlert.swift; sourceTree = ""; }; @@ -1095,6 +1097,7 @@ 4C4733152F1FA2AB005D2376 /* Components */ = { isa = PBXGroup; children = ( + 4C4734062F226B81005D2376 /* WorkshopMakeMenu.swift */, 4C4733FB2F22615D005D2376 /* WorkshopFilterChip.swift */, 4C4733FE2F22615D005D2376 /* WorkshopSkeletonBox.swift */, 4C4733FF2F22615D005D2376 /* WorkshopSortSheet.swift */, @@ -2679,6 +2682,7 @@ 4CF2A9682F0B91F300BA9FDA /* AnimatedGIFView.swift in Sources */, 4CC8D01F2EF0447100317467 /* ChangeNameViewModel.swift in Sources */, 4CC8D0202EF0447100317467 /* MyPageViewModel.swift in Sources */, + 4C4734072F226B81005D2376 /* WorkshopMakeMenu.swift in Sources */, 38C147C52EB1F16A00A8E511 /* CollectionViewModel+LoadData.swift in Sources */, C6C35F3A2ED2A3C2009642F4 /* FestivalViewModel.swift in Sources */, AAEB46AF2EC1D648002B13E5 /* BundleNameEditView.swift in Sources */, diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopMakeMenu.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopMakeMenu.swift new file mode 100644 index 000000000..a2db4c411 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopMakeMenu.swift @@ -0,0 +1,74 @@ +// +// WorkshopMakeMenu.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI + +struct WorkshopMakeMenu: View { + let position: CGRect + let onKeyring: () -> Void + let onBundle: () -> Void + + private let menuWidth: CGFloat = 132 + private let menuHeight: CGFloat = 115 + + @State private var isAppearing = false + + var body: some View { + GeometryReader { geometry in + ZStack { + VStack(alignment: .leading, spacing: 25) { + // 키링 버튼 + Button(action: onKeyring) { + HStack(spacing: 8) { + Image(.keyringMenuIcon) + + Text("키링") + .typography(.suit16M) + .foregroundColor(.gray600) + + Spacer() + } + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // 뭉치 버튼 + Button(action: onBundle) { + HStack(spacing: 8) { + Image(.bundleMenuIcon) + + Text("뭉치") + .typography(.suit16M) + .foregroundColor(.gray600) + + Spacer() + } + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(width: menuWidth, height: menuHeight) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 34)) + .scaleEffect(isAppearing ? 1.0 : 0.8, anchor: .topTrailing) + .opacity(isAppearing ? 1.0 : 0.0) + .position( + x: geometry.size.width - menuWidth / 2 - 16, + y: position.maxY - geometry.safeAreaInsets.top + 5 + menuHeight / 2 + ) + } + } + .onAppear { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isAppearing = true + } + } + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift index 93a839030..c69a90608 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift @@ -15,7 +15,7 @@ extension WorkshopView { HStack { titleView Spacer() - myItemBtn + makeBtn } .padding(.horizontal, 20) .frame(maxWidth: .infinity) @@ -67,9 +67,9 @@ extension WorkshopView { Image(.myItem) .resizable() .scaledToFit() - + Spacer() - + Text("내 아이템") .typography(.suit17B) .foregroundColor(.black) @@ -80,7 +80,37 @@ extension WorkshopView { .fixedSize(horizontal: true, vertical: true) .buttonStyle(.glass) } - + + /// 만들기 메뉴 버튼 + var makeBtn: some View { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showMakeMenu.toggle() + } + } label: { + HStack(spacing: 8) { + Image(.makingIcon) + Text("만들기") + .typography(.suit17B) + .foregroundStyle(.black100) + } + .padding(.vertical, 4) + .padding(.horizontal, 4) + } + .buttonStyle(.glass) + .background( + GeometryReader { geo in + Color.clear + .onAppear { + makeMenuPosition = geo.frame(in: .global) + } + .onChange(of: geo.frame(in: .global)) { _, newValue in + makeMenuPosition = newValue + } + } + ) + } + /// 상단 그라데이션 오버레이 var topGradientOverlay: some View { VStack { diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift index 7371e8445..e71064505 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift @@ -33,6 +33,10 @@ struct WorkshopView: View { @State private var isTabBarVisible = true @State var workshopToggle: Bool = true + // 만들기 메뉴 상태 + @State var showMakeMenu: Bool = false + @State var makeMenuPosition: CGRect = .zero + let categories = ["템플릿", "카라비너", "이펙트", "배경"] /// WorkshopTab에서 생성된 viewModel을 받아서 사용 @@ -74,6 +78,35 @@ struct WorkshopView: View { .sheet(isPresented: $viewModel.showFilterSheet) { sortSheet } + .overlay { + if showMakeMenu { + // 배경 탭으로 메뉴 닫기 + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showMakeMenu = false + } + } + + // 만들기 메뉴 + WorkshopMakeMenu( + position: makeMenuPosition, + onKeyring: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showMakeMenu = false + } + // TODO: - 키링 만들기 액션 + }, + onBundle: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showMakeMenu = false + } + // TODO: - 뭉치 만들기 액션 + } + ) + } + } .task { guard !hasInitialized else { return }