diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 83242c0e5..1b6744b6c 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -117,15 +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 */; }; 4C4733952F1FA388005D2376 /* ClearSketchDrawingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733652F1FA388005D2376 /* ClearSketchDrawingView.swift */; }; @@ -198,6 +191,22 @@ 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 */; }; + 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 */; }; + 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 */; }; @@ -546,17 +555,10 @@ 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 = ""; }; - 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 +630,22 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -1079,7 +1097,14 @@ 4C4733152F1FA2AB005D2376 /* Components */ = { isa = PBXGroup; children = ( - 4C4733142F1FA2AB005D2376 /* WorkshopComponents.swift */, + 4C4734062F226B81005D2376 /* WorkshopMakeMenu.swift */, + 4C4733FB2F22615D005D2376 /* WorkshopFilterChip.swift */, + 4C4733FE2F22615D005D2376 /* WorkshopSkeletonBox.swift */, + 4C4733FF2F22615D005D2376 /* WorkshopSortSheet.swift */, + 4C4733FA2F22615D005D2376 /* WorkshopFilterBar.swift */, + 4C4733FD2F22615D005D2376 /* WorkshopItemCard.swift */, + 4C4733FC2F22615D005D2376 /* WorkshopItemActionButton.swift */, + 4C4733F42F225785005D2376 /* WorkshopGridBuilder.swift */, ); path = Components; sourceTree = ""; @@ -1096,13 +1121,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 = ""; @@ -1140,6 +1166,7 @@ isa = PBXGroup; children = ( 4C4733352F1FA388005D2376 /* Recording */, + 4C4733F82F226143005D2376 /* TemplateActionButton.swift */, 4C4733362F1FA388005D2376 /* CameraPicker.swift */, 4C4733372F1FA388005D2376 /* CinematicAppearanceModifier.swift */, 4C4733382F1FA388005D2376 /* PreviewGuiding.swift */, @@ -2467,6 +2494,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 +2554,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 */, @@ -2570,22 +2599,23 @@ 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 */, 38173D112EB90CCE00E36F7E /* TagInputPopup.swift in Sources */, 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 */, - 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 */, 4CEBB1652EFBA54200CF53E2 /* RootViewModel.swift in Sources */, @@ -2652,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 */, @@ -2675,6 +2706,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/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/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/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/Components/WorkshopComponents.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift deleted file mode 100644 index e32a97d60..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 - } - } - - /// 탭 핸들러 (키링은 바로 만들기, 나머지는 WorkshopPreview로 이동) - 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 - } - - // 나머지 아이템들은 WorkshopPreview로 이동 - 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/Main/WorkshopGridHelpers.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift similarity index 91% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopGridHelpers.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift index d39bdeeb6..b6f8b7c91 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( @@ -27,7 +27,7 @@ struct WorkshopGridHelpers { GridItem(.flexible()) ], spacing: 11) { ForEach(items) { item in - WorkshopItemView( + WorkshopItemCard( item: item, isOwned: isOwnedCheck(item), router: router, @@ -61,7 +61,7 @@ struct WorkshopGridHelpers { ], 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 WorkshopGridHelpers { ) .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/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/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)]) + } +} 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/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..179bc80e1 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() @@ -270,7 +270,7 @@ extension WorkshopPreview { .frame(width: 38, height: 38) if isDownloading { - CircularProgressView(progress: progress) + DownloadProgressRing(progress: progress) .frame(width: 20, height: 20) } else { Image(.whitePolygon) @@ -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..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 ) { @@ -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/WorkshopTopBannerSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift deleted file mode 100644 index 378b10de3..000000000 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// WorkshopTopBannerSection.swift -// Keychy -// -// Created by rundo on 11/3/25. -// - -import SwiftUI - -// MARK: - Top Banner Section - -extension WorkshopView { - /// 상단 배너 (코인 버튼 + 타이틀) - var topBannerSection: some View { - HStack { - titleView - Spacer() - myItemBtn - } - .padding(.horizontal, 20) - .frame(maxWidth: .infinity) - } - - /// 스크롤 시 나타나는 상단 타이틀 바 - var topTitleBar: some View { - HStack { - titleView - Spacer() - myItemBtn - } - .padding(.top, 60) - .padding(.horizontal, 20) - .padding(.bottom, 24) - .background(Color.white100) - .opacity(viewModel.mainContentOffset - 80 < 70 ? 1 : 0) - } - - /// 타이틀 뷰 - var titleView: some View { - HStack(spacing: 10) { - Button { - // TODO: - 공방 탭 액션 - workshopToggle = true - } label: { - Text("키링") - .typography(.nanum24EB) - .foregroundStyle(workshopToggle ? .black100 : .gray100) - } - - Button { - // TODO: - 뭉치 탭 액션 - workshopToggle = false - } label: { - Text("뭉치") - .typography(.nanum24EB) - .foregroundStyle(workshopToggle ? .gray100 : .black100) - } - } - } - - /// 내 아이템 버튼 - var myItemBtn: some View { - Button { - router.push(.myItems) - } label: { - HStack(spacing: 0) { - Image(.myItem) - .resizable() - .scaledToFit() - - Spacer() - - Text("내 아이템") - .typography(.suit17B) - .foregroundColor(.black) - } - } - .frame(minWidth: 80) - .frame(height: 44) - .fixedSize(horizontal: true, vertical: true) - .buttonStyle(.glass) - } -} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMainContentSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift similarity index 93% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMainContentSection.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift index 48d2c5af9..af2f54616 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. @@ -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) @@ -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 75% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopStickyHeaderSection.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+StickyHeader.swift index b25903c65..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. @@ -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/WorkshopView+TopSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift new file mode 100644 index 000000000..c69a90608 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift @@ -0,0 +1,144 @@ +// +// WorkshopView+TopSection.swift +// Keychy +// +// Created by rundo on 11/3/25. +// + +import SwiftUI + +// MARK: - Top Section + +extension WorkshopView { + /// 상단 배너 (코인 버튼 + 타이틀) + var topBannerSection: some View { + HStack { + titleView + Spacer() + makeBtn + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity) + } + + /// 스크롤 시 나타나는 상단 타이틀 바 + var topTitleBar: some View { + HStack { + titleView + Spacer() + myItemBtn + } + .padding(.top, WorkshopLayout.topPadding) + .padding(.horizontal, 20) + .padding(.bottom, 24) + .background(Color.white100) + .opacity(viewModel.mainContentOffset - WorkshopLayout.titleBarOpacityThreshold < WorkshopLayout.titleBarOpacityRange ? 1 : 0) + } + + /// 타이틀 뷰 + var titleView: some View { + HStack(spacing: 10) { + Button { + // TODO: - 공방 탭 액션 + workshopToggle = true + } label: { + Text("키링") + .typography(.nanum24EB) + .foregroundStyle(workshopToggle ? .black100 : .gray100) + } + + Button { + // TODO: - 뭉치 탭 액션 + workshopToggle = false + } label: { + Text("뭉치") + .typography(.nanum24EB) + .foregroundStyle(workshopToggle ? .gray100 : .black100) + } + } + } + + /// 내 아이템 버튼 + var myItemBtn: some View { + Button { + router.push(.myItems) + } label: { + HStack(spacing: 0) { + Image(.myItem) + .resizable() + .scaledToFit() + + Spacer() + + Text("내 아이템") + .typography(.suit17B) + .foregroundColor(.black) + } + } + .frame(minWidth: 80) + .frame(height: 44) + .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 { + 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 7397d42d9..e71064505 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 { @@ -19,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을 받아서 사용 @@ -46,22 +64,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) @@ -75,27 +78,42 @@ struct WorkshopView: View { .sheet(isPresented: $viewModel.showFilterSheet) { sortSheet } - .task { - // 네트워크 체크 - guard NetworkManager.shared.isConnected else { - viewModel.hasNetworkError = true - return - } + .overlay { + if showMakeMenu { + // 배경 탭으로 메뉴 닫기 + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showMakeMenu = false + } + } - // 최초 한 번만 초기화 - if !hasInitialized { - viewModel = WorkshopViewModel(userManager: userManager) - hasInitialized = true + // 만들기 메뉴 + 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 } - // 1. 현재 선택된 카테고리만 먼저 로드 (빠른 초기 화면) - await viewModel.fetchDataForCategory(viewModel.selectedCategory) - // Workshop 배너는 Home에서 이미 prefetch됨 + viewModel = WorkshopViewModel(userManager: userManager) + hasInitialized = true - // 2. 백그라운드에서 나머지 카테고리 프리페칭 - Task.detached(priority: .background) { - await viewModel.prefetchRemainingData() - } - } + await viewModel.initialize() } .onChange(of: viewModel.selectedCategory) { oldValue, newValue in viewModel.resetFilters() @@ -108,8 +126,7 @@ struct WorkshopView: View { .withToast(position: .tabbar) } - // MARK: Main Content - + // MARK: - Main Content /// 메인 스크롤 콘텐츠 var mainScrollContent: some View { ScrollView(showsIndicators: false) { @@ -118,13 +135,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 +158,7 @@ struct WorkshopView: View { } ) } - .padding(.top, 60) + .padding(.top, WorkshopLayout.topPadding) .background(alignment: .top) { Image(.workshopKeyringBGF) .resizable() @@ -151,59 +168,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) - } - } -} 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 000000000..cc681492f Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleMenuIcon.imageset/bundleMenuIcon.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/keyringMenuIcon.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/keyringMenuIcon.imageset/Contents.json new file mode 100644 index 000000000..fe1f9fcf8 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/keyringMenuIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "keyringMenuIcon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/keyringMenuIcon.imageset/keyringMenuIcon.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/keyringMenuIcon.imageset/keyringMenuIcon.pdf new file mode 100644 index 000000000..8415cf3ab Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/keyringMenuIcon.imageset/keyringMenuIcon.pdf differ 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 000000000..85b669480 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/makingIcon.imageset/makingIcon.pdf differ