diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 68036c545..e64fa0fd8 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -245,6 +245,7 @@ 4C84A1602EB134BD008FFE57 /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A596832EAFEAA20003D712 /* ProfileSetupView.swift */; }; 4C86A6122F25C0B10023AA2D /* WorkshopBundleGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */; }; 4C86A6142F25C0BA0023AA2D /* WorkshopKeyringGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */; }; + 4C86A6182F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6172F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift */; }; 4CA9C6A62EC9D11600CA546B /* CustomNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9C6A42EC9D11600CA546B /* CustomNavigationBar.swift */; }; 4CA9C6A82EC9DB5300CA546B /* View+SafeAreaBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9C6A72EC9DB5300CA546B /* View+SafeAreaBottom.swift */; }; 4CA9C6D62ECB7AEA00CA546B /* BadWords.json in Resources */ = {isa = PBXBuildFile; fileRef = 4CA9C6D52ECB7AEA00CA546B /* BadWords.json */; }; @@ -685,6 +686,7 @@ 4C8426632ED375840050B6FE /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = ""; }; 4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopBundleGridView.swift; sourceTree = ""; }; 4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopKeyringGridView.swift; sourceTree = ""; }; + 4C86A6172F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopTemplateSelectSheet.swift; sourceTree = ""; }; 4CA9C6A42EC9D11600CA546B /* CustomNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNavigationBar.swift; sourceTree = ""; }; 4CA9C6A72EC9DB5300CA546B /* View+SafeAreaBottom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+SafeAreaBottom.swift"; sourceTree = ""; }; 4CA9C6D52ECB7AEA00CA546B /* BadWords.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = BadWords.json; sourceTree = ""; }; @@ -1439,6 +1441,7 @@ children = ( 4C4733E42F20FE34005D2376 /* WorkshopRecentTemplate.swift */, 4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */, + 4C86A6172F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift */, ); path = Keyring; sourceTree = ""; @@ -2589,6 +2592,7 @@ 4C4733D52F1FA388005D2376 /* AcrylicPhotoVM+CropBoxDrag.swift in Sources */, 4C4733D62F1FA388005D2376 /* SpeechBubbleVM+Customizing.swift in Sources */, 4C4733D72F1FA388005D2376 /* AcrylicPhotoVM+Crop.swift in Sources */, + 4C86A6182F276CFD0023AA2D /* WorkshopTemplateSelectSheet.swift in Sources */, 4C4733D82F1FA388005D2376 /* AcrylicPhotoPreView.swift in Sources */, 4C4733D92F1FA388005D2376 /* TemplatePreviewComponents.swift in Sources */, 4C004FA42F177C4600D9063E /* BundleVideoGenerator+Setup.swift in Sources */, diff --git a/Keychy/Keychy/Core/Components/View/Popup/InvenExpandPopup.swift b/Keychy/Keychy/Core/Components/View/Popup/InvenExpandPopup.swift index 126ea5465..d14a81cd9 100644 --- a/Keychy/Keychy/Core/Components/View/Popup/InvenExpandPopup.swift +++ b/Keychy/Keychy/Core/Components/View/Popup/InvenExpandPopup.swift @@ -64,16 +64,11 @@ struct InvenExpandPopup: View { Button(action: onConfirm) { HStack(spacing: 4) { Image(.myCoinMini) - .resizable() - .frame(width: 34, height: 34) - .padding(.bottom, 4) Text("\(price)") .typography(.nanum18EB) .foregroundColor(.white100) .frame(height: 32) - .padding(.top, 4) - } .frame(maxWidth: .infinity) .frame(height: 48) diff --git a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift index 3d3422ce0..cc18e3f1b 100644 --- a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift +++ b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift @@ -72,6 +72,7 @@ struct Typography { // MARK: - Nanum static let nanum20EB = Typography(font: .custom(.nanumExtraBold, size: 20), lineSpacing: 0) static let nanum18EB = Typography(font: .custom(.nanumExtraBold, size: 18), lineSpacing: 0) + static let nanum17EB = Typography(font: .custom(.nanumExtraBold, size: 17), lineSpacing: 0) static let nanum16EB = Typography(font: .custom(.nanumExtraBold, size: 16), lineSpacing: 0) static let nanum15EB25 = Typography(font: .custom(.nanumExtraBold, size: 15), lineSpacing: 10) diff --git a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift index 64c061733..911cc3010 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift @@ -7,13 +7,22 @@ import Foundation /// 공방 탭 라우팅 (마켓플레이스 + 키링 제작) -enum WorkshopRoute: Hashable { +enum WorkshopRoute: Hashable, BundleRoute { // MARK: - 공방 마켓플레이스 case workshopPreview(item: AnyHashable) case coinCharge case myItems case workshopTemplates + // MARK: - 번들 만들기 + case bundleInventoryView + case bundleDetailView + case bundleCreateView + case bundleAddKeyringView + case bundleNameInputView + case bundleNameEditView + case bundleEditView + // MARK: - Festival 임시 라우트 (추후 정리 예정) case showcase25BoardView case festivalKeyringDetailView(Keyring) diff --git a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift index f132802d1..b42d3e1b8 100644 --- a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift +++ b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift @@ -49,6 +49,10 @@ class BundleViewModel { var carabiners: [Carabiner] { dataManager.carabiners } var selectedCarabiner: Carabiner? + + // 공방에서 "뭉치에 사용하기"로 진입 시 미리 선택할 아이템 ID + var preSelectedBackgroundId: String? + var preSelectedCarabinerId: String? // 뭉치 이름 최대 글자 수 var maxBundleNameCount: Int = 9 diff --git a/Keychy/Keychy/Presentation/Bundle/Views/BundleCreateView.swift b/Keychy/Keychy/Presentation/Bundle/Views/BundleCreateView.swift index 7a44589e6..0d95a51cd 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/BundleCreateView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/BundleCreateView.swift @@ -281,27 +281,45 @@ extension BundleCreateView { // 배경 데이터 로드 await withCheckedContinuation { continuation in bundleVM.fetchAllBackgrounds { _ in - // "키치 배경"을 기본으로 선택, 없으면 첫 번째 선택 if self.selectedBackground == nil { - self.selectedBackground = bundleVM.backgroundViewData.first { bg in - bg.background.backgroundName == "키치 배경" - } ?? bundleVM.backgroundViewData.first + // 공방에서 미리 선택된 배경이 있으면 해당 배경 선택 + if let preSelectedId = bundleVM.preSelectedBackgroundId { + self.selectedBackground = bundleVM.backgroundViewData.first { bg in + bg.background.id == preSelectedId + } + bundleVM.preSelectedBackgroundId = nil // 사용 후 초기화 + } + // 미리 선택된 배경이 없으면 "퍼플키치"를 기본으로 선택, 없으면 첫 번째 선택 + if self.selectedBackground == nil { + self.selectedBackground = bundleVM.backgroundViewData.first { bg in + bg.background.backgroundName == "퍼플키치" + } ?? bundleVM.backgroundViewData.first + } } - + continuation.resume() } } - + // 카라비너 데이터 로드 await withCheckedContinuation { continuation in bundleVM.fetchAllCarabiners { _ in - // "키치 카라비너"를 기본으로 선택, 없으면 첫 번째 선택 if self.selectedCarabiner == nil { - self.selectedCarabiner = bundleVM.carabinerViewData.first { cb in - cb.carabiner.carabinerName == "키치 카라비너" - } ?? bundleVM.carabinerViewData.first + // 공방에서 미리 선택된 카라비너가 있으면 해당 카라비너 선택 + if let preSelectedId = bundleVM.preSelectedCarabinerId { + self.selectedCarabiner = bundleVM.carabinerViewData.first { cb in + cb.carabiner.id == preSelectedId + } + bundleVM.preSelectedCarabinerId = nil // 사용 후 초기화 + } + // 미리 선택된 카라비너가 없으면 "웰컴 키치"를 기본으로 선택, 없으면 첫 번째 선택 + if self.selectedCarabiner == nil { + self.selectedCarabiner = bundleVM.carabinerViewData.first { cb in + cb.carabiner.carabinerName == "웰컴 키치" + } ?? bundleVM.carabinerViewData.first + } } - + continuation.resume() } } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplateActionButton.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplateActionButton.swift index f63e2ba3d..8d1894c25 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplateActionButton.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplateActionButton.swift @@ -10,9 +10,9 @@ import SwiftUI // MARK: - Template Action Button /// 키링 템플릿 전용 액션 버튼 -/// - 보유중이면 "만들기" 버튼 표시 (활성화) +/// - 보유중이면 "키링 만들기" 버튼 표시 (활성화) /// - 유료이고 미보유면 구매 버튼 표시 -/// - 무료이고 미보유면 "만들기" 버튼 표시 (활성화) +/// - 무료이고 미보유면 "키링 만들기" 버튼 표시 (활성화) struct TemplateActionButton: View { let template: KeyringTemplate let isOwned: Bool @@ -36,7 +36,7 @@ struct TemplateActionButton: View { Button { onMake() } label: { - Text("만들기") + Text("키링 만들기") .typography(.suit17B) .foregroundStyle(.white100) .frame(maxWidth: .infinity) @@ -53,9 +53,6 @@ struct TemplateActionButton: View { } label: { HStack(spacing: 5) { Image(.myCoinMini) - .resizable() - .scaledToFit() - .frame(width: 32) Text("\(template.workshopPrice)") .typography(.nanum18EB) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplatePreviewComponents.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplatePreviewComponents.swift index e4de01c81..10c73c696 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplatePreviewComponents.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Components/TemplatePreviewComponents.swift @@ -71,7 +71,7 @@ struct TemplatePreviewBody: View { } center: { Spacer() } trailing: { - Spacer() + coinButton } } .ignoresSafeArea() @@ -309,4 +309,23 @@ extension TemplatePreviewBody { } } } + + /// 코인 충전 버튼 + private var coinButton: some View { + Button { + router?.push(.coinCharge) + } label: { + HStack(spacing: 8) { + Image(.myCoinMini) + + Text("\((userManager.currentUser?.coin ?? 0).formatted())") + .typography(.nanum17EB) + .foregroundColor(.black) + } + .padding(.horizontal, 16) + .padding(.vertical, 12.5) + } + .buttonStyle(.plain) + .glassEffect(.regular.interactive(), in: .capsule) + } } diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index a4b3ab83e..d74c2f2a5 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -20,6 +20,10 @@ struct WorkshopTab: View { @State private var speechBubbleVM: SpeechBubbleVM? @State private var workshopViewModel = WorkshopViewModel(userManager: UserManager.shared) + // Bundle 관련 ViewModel + @State private var collectionViewModel = CollectionViewModel() + @State private var bundleViewModel = BundleViewModel() + var body: some View { NavigationStack(path: $router.path) { WorkshopView( @@ -42,9 +46,9 @@ struct WorkshopTab: View { if let template = item.base as? KeyringTemplate { WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: template) } else if let background = item.base as? Background { - WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: background) + WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: background, bundleViewModel: bundleViewModel) } else if let carabiner = item.base as? Carabiner { - WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: carabiner) + WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: carabiner, bundleViewModel: bundleViewModel) } else if let particle = item.base as? Particle { WorkshopItemDetailView(router: router, viewModel: workshopViewModel, item: particle) } else if let sound = item.base as? Sound { @@ -233,6 +237,22 @@ struct WorkshopTab: View { festivalVM.onKeyringCompleteFromFestival?(router) } : nil ) + + // MARK: - Bundle + case .bundleInventoryView: + BundleInventoryView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) + case .bundleDetailView: + BundleDetailView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) + case .bundleCreateView: + BundleCreateView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) + case .bundleAddKeyringView: + BundleAddKeyringView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) + case .bundleNameInputView: + BundleNameInputView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) + case .bundleNameEditView: + BundleNameEditView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) + case .bundleEditView: + BundleEditView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) } } diff --git a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift index 6512ded48..482d037ba 100644 --- a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift +++ b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift @@ -36,7 +36,7 @@ enum QuickFilter: CaseIterable { var icon: ImageResource? { switch self { case .free: return nil - case .owned: return .quickFilterOwned + case .owned: return .workshopOwnedIcon } } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleBanner.swift b/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleBanner.swift index 0a3213689..4cf88e76c 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleBanner.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleBanner.swift @@ -1,81 +1,33 @@ // -// WorkshopMakingKeyringSection.swift +// WorkshopBundleBanner.swift // Keychy // -// Created by rundo on 11/24/25. +// Created by 길지훈 on 1/25/26. // import SwiftUI -import NukeUI +import Combine -// MARK: - MakingKeyring Section +/// 번들 탭 배너 - 1초마다 이미지 전환 +struct WorkshopBundleBanner: View { + @State private var currentIndex = 0 -extension WorkshopView { - var WorkshopBundleBanner: some View { - VStack(spacing: 0) { - // 제목 - Text("내 마음대로 고르는\n다양한 템플릿(๑' ᵕ '๑)⸝*") - .typography(.suit16B) - .frame(maxWidth: .infinity, alignment: .leading) - .multilineTextAlignment(.leading) - .padding(.horizontal, 20) - .padding(.top, 13) - .padding(.bottom, 10) - - workshopBannerImage - - // 키링 만들기 버튼 - Button { - router.push(.workshopTemplates) - } label: { - ZStack { - // 바탕 레이어 - RoundedRectangle(cornerRadius: 15) - .fill(Color.main400) - .frame(maxWidth: .infinity) - .frame(height: 48) - - // 버튼 제목 - Text("+ 키링 만들기") - .typography(.suit17B) - .foregroundStyle(Color.white100) - } - } - .padding(.horizontal, 10) - .padding(.bottom, 6) - } - .background(Color.white50) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.white70, lineWidth: 1) - ) - .padding(.horizontal, 15) - .padding(.bottom, 12) - } - - // MARK: - Workshop Banner Image - private var workshopBannerImage: some View { - ZStack { - // GIF (항상 렌더링 - 백그라운드에서 로드) - if let url = viewModel.workshopBannerURL { - NukeAnimatedImageView( - url: url, - isLoading: $viewModel.isWorkshopBannerLoading, - maxSize: CGSize(width: 1800, height: 1800) - ) - } + private let images: [ImageResource] = [ + .bundleBanner1, + .bundleBanner2, + .bundleBanner3, + .bundleBanner4 + ] - // 썸네일 (로딩 중에만 GIF 위에 덮음 - 정지 상태) - if viewModel.isWorkshopBannerLoading, let thumbnailImage = viewModel.workshopThumbnailImage { - Image(uiImage: thumbnailImage) - .resizable() - .scaledToFit() + private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() + + var body: some View { + Image(images[currentIndex]) + .resizable() + .aspectRatio(contentMode: .fit) + .onReceive(timer) { _ in + currentIndex = (currentIndex + 1) % images.count } - } - .frame(height: 120) - .padding(.bottom, 10) + .padding(.horizontal, 8.5) } } - - diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift index 676f97b88..3b3112856 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemActionButton.swift @@ -10,39 +10,68 @@ import SwiftUI // MARK: - Item Action Button /// WorkshopItem (배경, 카라비너, 이펙트 등) 전용 액션 버튼 -/// - 무료면 "무료" 비활성화 버튼 -/// - 보유중이면 "보유중" 비활성화 버튼 -/// - 유료이고 미보유면 구매 버튼 +/// - 무료 또는 보유중: 배경/카라비너 → "뭉치에 사용하기", 그 외 → "보유중" +/// - 유료이고 미보유 → 구매 버튼 struct WorkshopItemActionButton: View { let item: any WorkshopItem let isOwned: Bool let onPurchase: () -> Void + var onUseInBundle: (() -> Void)? = nil + + /// 배경 또는 카라비너인지 확인 + private var isBundleItem: Bool { + item is Background || item is Carabiner + } + + /// 무료이거나 보유중인지 확인 + private var isAvailable: Bool { + item.isFree || isOwned + } var body: some View { Group { - if item.isFree { - disabledButton(text: "무료") - } else if isOwned { - disabledButton(text: "보유중") + if isAvailable { + // 무료 또는 보유중 + if isBundleItem { + useInBundleButton + } else { + ownedButton + } } else { + // 유료이고 미보유 purchaseButton } } } - /// 비활성화 버튼 (무료 / 보유중) - private func disabledButton(text: String) -> some View { + /// 뭉치에 사용하기 버튼 (배경/카라비너) + private var useInBundleButton: some View { + Button { + onUseInBundle?() + } label: { + Text("뭉치에 사용하기") + .typography(.suit17B) + .foregroundStyle(.white100) + .frame(maxWidth: .infinity) + .padding(.vertical, 7.5) + } + .buttonStyle(.glassProminent) + .tint(.main500) + } + + /// 보유중 버튼 (파티클/사운드) + private var ownedButton: some View { Button { - // 비활성화 - 아무 동작 없음 + // 동작 없음 } label: { - Text(text) + Text("보유중") .typography(.suit17B) - .foregroundStyle(.gray400) + .foregroundStyle(.white100) .frame(maxWidth: .infinity) .padding(.vertical, 7.5) } .buttonStyle(.glassProminent) - .tint(.white100) + .tint(.gray400) .disabled(true) } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift index 696fe5a72..c470f0ea3 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift @@ -152,36 +152,40 @@ struct WorkshopPriceOverlay: View { var body: some View { ZStack { - // 유료 아이콘 + // 유료: 오른쪽 상단에 가격 또는 보유 표시 VStack { HStack { - Image(.myCoin) Spacer() - } - .padding(.top, 9.84) - .padding(.leading, 9.35) - 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) - ) + if isOwned { + // 보유 + Text("보유") + .typography(.suit13M) + .foregroundStyle(.white100) + .padding(.vertical, 4) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.black60) + ) + } else { + // 미보유: 가격 표시 + Text("\(price)") + .typography(.nanum16EB) + .foregroundStyle(.white100) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .padding(.top, 3) + .padding(.leading, -1) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.mainOpacity80) + ) + } } .padding(10) Spacer() } - .opacity(isOwned ? 1 : 0) + .opacity(isFree ? 0 : 1) } // 사운드인 경우에만 재생 버튼 표시 diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopTemplateSelectSheet.swift b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopTemplateSelectSheet.swift new file mode 100644 index 000000000..e7976b0b7 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopTemplateSelectSheet.swift @@ -0,0 +1,262 @@ +// +// WorkshopTemplateSelectSheet.swift +// Keychy +// +// Created by 길지훈 on 1/26/26. +// + +import SwiftUI + +/// 템플릿 선택 필터 (카테고리) +enum WorkshopTemplateSelectFilter: String, CaseIterable { + case owned = "마이" + case free = "무료" + case image = "이미지" + case text = "텍스트" + case drawing = "드로잉" +} + +/// 템플릿 정렬 순서 +enum WorkshopTemplateSortOrder: String, CaseIterable { + case latest = "최신순" + case popular = "인기순" +} + +/// 키링 만들기 - 템플릿 선택 시트 +struct WorkshopTemplateSelectSheet: View { + @Binding var isPresented: Bool + @Bindable var router: NavigationRouter + let templates: [KeyringTemplate] + + @Environment(UserManager.self) private var userManager + @State private var selectedFilter: WorkshopTemplateSelectFilter? + @State private var sortOrder: WorkshopTemplateSortOrder = .latest + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 15.5), count: 3) + + var body: some View { + VStack(spacing: 0) { + // 타이틀 + Text("템플릿 선택") + .typography(.suit15SB25) + .padding(.top, 29) + .padding(.bottom, 22) + + // 필터 바 + filterBar + .padding(.bottom, 22) + + // 템플릿 그리드 (캐시된 데이터 사용) + ScrollView(showsIndicators: false) { + if filteredTemplates.isEmpty { + emptyView + } else { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(filteredTemplates, id: \.id) { template in + templateCard(template) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + } + } + .background(.white100) + .presentationDetents([.fraction(0.95)]) + .presentationDragIndicator(.hidden) + } + + // MARK: - Filter Bar + private var filterBar: some View { + VStack(alignment: .leading, spacing: 15) { + // 카테고리 필터 칩 (상단) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(WorkshopTemplateSelectFilter.allCases, id: \.self) { filter in + let isSelected = selectedFilter == filter + let icon: Image? = filter == .owned + ? (isSelected ? Image(.quickFilterCheckedOff) : Image(.quickFilterCheckedOn)) + : nil + + filterChip( + title: filter.rawValue, + isSelected: isSelected, + icon: icon + ) { + // 같은 필터 다시 누르면 해제 (전체) + selectedFilter = isSelected ? nil : filter + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 2) + } + + // 정렬 메뉴 (하단) + Menu { + ForEach(WorkshopTemplateSortOrder.allCases, id: \.self) { order in + Button { + sortOrder = order + } label: { + HStack { + Text(order.rawValue) + if sortOrder == order { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(sortOrder.rawValue) + .typography(.suit14SB18) + .foregroundColor(.gray500) + Image(systemName: "chevron.down") + .foregroundColor(.gray500) + } + } + .padding(.horizontal, 31) + } + } + + // MARK: - Filter Chip (카테고리용) + private func filterChip( + title: String, + isSelected: Bool, + icon: Image? = nil, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 6) { + if let icon = icon { + icon + } + Text(title) + .typography(.suit15M) + .foregroundColor(isSelected ? .white100 : .main500) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 17) + .fill(isSelected ? Color.main500 : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 17) + .stroke(Color.main500, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + // MARK: - Template Card + private func templateCard(_ template: KeyringTemplate) -> some View { + let isOwned = userManager.currentUser?.templates.contains(template.id ?? "") ?? false + + return Button { + // 템플릿 선택 → 해당 Preview로 이동 + if let route = WorkshopRoute.from(string: template.id ?? "") { + isPresented = false + DispatchQueue.main.async { + router.push(route) + } + } + } label: { + VStack(spacing: 8) { + // 썸네일 + 가격 오버레이 (공방 스타일) + ZStack(alignment: .top) { + SimpleAnimatedImage(url: template.thumbnailURL) + .aspectRatio(contentMode: .fit) + .padding(.vertical, 10) + .clipped() + .frame(width: 105, height: 140.61) + + // 유료/보유 오버레이 + VStack { + HStack { + // 유료 아이콘 (왼쪽 상단) - 유료일 때 (보유 여부 상관없이) + Image(.myCoinMini) + .opacity(template.isFree ? 0 : 1) + + Spacer() + + // 보유 뱃지 (오른쪽 상단) - 보유 또는 무료일 때 + Text("보유") + .typography(.suit13M) + .foregroundStyle(.white100) + .padding(.vertical, 1) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.black60) + ) + .opacity(isOwned || template.isFree ? 1 : 0) + } + .padding(.top, 6) + .padding(.horizontal, 7) + Spacer() + } + } + .frame(width: 105, height: 140.61) + .background(Color.gray50) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.gray50, lineWidth: 2) + ) + + // 템플릿 이름 + Text(template.name) + .typography(.suit14SB18) + .lineLimit(1) + } + } + .buttonStyle(.plain) + } + + // MARK: - Empty View + private var emptyView: some View { + VStack(spacing: 12) { + Image(.emptyViewIcon) + .resizable() + .scaledToFit() + .frame(width: 100) + Text("템플릿이 없어요") + .typography(.suit15R) + .foregroundStyle(.gray400) + } + .padding(.top, 100) + } + + // MARK: - Filtering + private var filteredTemplates: [KeyringTemplate] { + var result = templates + + // 1. 카테고리 필터 (nil이면 전체) + if let filter = selectedFilter { + switch filter { + case .free: + result = result.filter { $0.isFree } + case .owned: + let ownedIds = userManager.currentUser?.templates ?? [] + result = result.filter { ownedIds.contains($0.id ?? "") } + case .image: + result = result.filter { $0.tags.contains("이미지") } + case .text: + result = result.filter { $0.tags.contains("텍스트") } + case .drawing: + result = result.filter { $0.tags.contains("드로잉") } + } + } + + // 2. 정렬 + switch sortOrder { + case .latest: + result.sort { $0.createdAt > $1.createdAt } + case .popular: + result.sort { $0.useCount > $1.useCount } + } + + return result + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift index 179bc80e1..e1999db63 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopItemDetailView.swift @@ -24,6 +24,7 @@ struct WorkshopItemDetailView: View { let viewModel: WorkshopViewModel let item: any WorkshopItem + var bundleViewModel: BundleViewModel? /// 아이템 보유 여부 확인 private var isOwned: Bool { @@ -86,7 +87,7 @@ struct WorkshopItemDetailView: View { } center: { Spacer() } trailing: { - Spacer() + coinButton } } .ignoresSafeArea() @@ -317,6 +318,15 @@ extension WorkshopItemDetailView { withAnimation(.spring(response: 0.6, dampingFraction: 0.5)) { purchasePopupScale = 1.0 } + }, + onUseInBundle: { + // 배경/카라비너를 뭉치 만들기에서 사용 + if let background = item as? Background { + bundleViewModel?.preSelectedBackgroundId = background.id + } else if let carabiner = item as? Carabiner { + bundleViewModel?.preSelectedCarabinerId = carabiner.id + } + router.push(.bundleCreateView) } ) } @@ -393,4 +403,23 @@ extension WorkshopItemDetailView { print("구매 실패: \(message)") } } + + /// 코인 충전 버튼 + private var coinButton: some View { + Button { + router.push(.coinCharge) + } label: { + HStack(spacing: 8) { + Image(.myCoinMini) + + Text("\((userManager.currentUser?.coin ?? 0).formatted())") + .typography(.nanum17EB) + .foregroundColor(.black) + } + .padding(.horizontal, 16) + .padding(.vertical, 12.5) + } + .buttonStyle(.plain) + .glassEffect(.regular.interactive(), in: .capsule) + } } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift index c12da5297..f0bf60cf2 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift @@ -12,7 +12,8 @@ import NukeUI enum WorkshopLayout { static let topPadding: CGFloat = 60 - static let recentTemplateTopSpacing: CGFloat = 106 + static let recentTemplateTopSpacing: CGFloat = 116 + static let bundleBannerTopSpacing: CGFloat = 20 static let mainContentTopSpacing: CGFloat = 43 static let gradientHeight: CGFloat = 100 static let stickyHeaderMinOffset: CGFloat = 120 @@ -35,6 +36,9 @@ struct WorkshopView: View { @State var showMakeMenu: Bool = false @State var makeMenuPosition: CGRect = .zero + // 템플릿 선택 시트 상태 + @State private var showTemplateSelectSheet = false + /// WorkshopTab에서 생성된 viewModel을 받아서 사용 init( router: NavigationRouter, @@ -62,11 +66,11 @@ struct WorkshopView: View { // 상단 그라데이션 블러 오버레이 topGradientOverlay } - .background( - Image(.workshopKeyringBGB) + .background { + Image(viewModel.workshopToggle ? .workshopKeyringBGB : .workshopBundleBGB) .resizable() .scaledToFill() - ) + } } } .ignoresSafeArea() @@ -91,13 +95,13 @@ struct WorkshopView: View { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { showMakeMenu = false } - // TODO: - 키링 만들기 액션 + showTemplateSelectSheet = true }, onBundle: { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { showMakeMenu = false } - // TODO: - 뭉치 만들기 액션 + router.push(.bundleCreateView) } ) } @@ -119,6 +123,16 @@ struct WorkshopView: View { } } .withToast(position: .tabbar) + .onAppear { + TabBarManager.show() + } + .sheet(isPresented: $showTemplateSelectSheet) { + WorkshopTemplateSelectSheet( + isPresented: $showTemplateSelectSheet, + router: router, + templates: viewModel.templates + ) + } } // MARK: - Main Content @@ -130,11 +144,14 @@ struct WorkshopView: View { topBannerSection Spacer() - .frame(height: WorkshopLayout.recentTemplateTopSpacing) + .frame(height: viewModel.workshopToggle ? + WorkshopLayout.recentTemplateTopSpacing : WorkshopLayout.bundleBannerTopSpacing) - // 키링 탭일 때만 최근 사용 템플릿 표시 + // 키링 탭: 최근 사용 템플릿 / 번들 탭: 배너 if viewModel.workshopToggle { recentTemplateSection + } else { + WorkshopBundleBanner() } Spacer() @@ -157,7 +174,7 @@ struct WorkshopView: View { } .padding(.top, WorkshopLayout.topPadding) .background(alignment: .top) { - Image(.workshopKeyringBGF) + Image(viewModel.workshopToggle ? .workshopKeyringBGF : .workshopBundleBGF) .resizable() .aspectRatio(contentMode: .fit) } diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner1.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner1.imageset/Contents.json new file mode 100644 index 000000000..7bd9c95c8 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bundleBanner1.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner1.imageset/bundleBanner1.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner1.imageset/bundleBanner1.pdf new file mode 100644 index 000000000..677deb451 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner1.imageset/bundleBanner1.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner2.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner2.imageset/Contents.json new file mode 100644 index 000000000..e7ccb10fb --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bundleBanner2.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner2.imageset/bundleBanner2.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner2.imageset/bundleBanner2.pdf new file mode 100644 index 000000000..d787042f8 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner2.imageset/bundleBanner2.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner3.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner3.imageset/Contents.json new file mode 100644 index 000000000..15587db4e --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bundleBanner3.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner3.imageset/bundleBanner3.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner3.imageset/bundleBanner3.pdf new file mode 100644 index 000000000..3cfd90199 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner3.imageset/bundleBanner3.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner4.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner4.imageset/Contents.json new file mode 100644 index 000000000..50a911371 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bundleBanner4.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner4.imageset/bundleBanner4.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner4.imageset/bundleBanner4.pdf new file mode 100644 index 000000000..1d0f925a5 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/bundleBanner4.imageset/bundleBanner4.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOff.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOff.imageset/Contents.json new file mode 100644 index 000000000..2651ddfed --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOff.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "quickFilterCheckedOff.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOff.imageset/quickFilterCheckedOff.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOff.imageset/quickFilterCheckedOff.pdf new file mode 100644 index 000000000..0ec22a1d5 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOff.imageset/quickFilterCheckedOff.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOn.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOn.imageset/Contents.json new file mode 100644 index 000000000..b2094e3e8 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOn.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "quickFilterCheckedOn.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOn.imageset/quickFilterCheckedOn.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOn.imageset/quickFilterCheckedOn.pdf new file mode 100644 index 000000000..9a1819123 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterCheckedOn.imageset/quickFilterCheckedOn.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGB.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGB.imageset/Contents.json new file mode 100644 index 000000000..c341e68ca --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGB.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "workshopBundleBGB.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGB.imageset/workshopBundleBGB.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGB.imageset/workshopBundleBGB.pdf new file mode 100644 index 000000000..276aea259 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGB.imageset/workshopBundleBGB.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGF.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGF.imageset/Contents.json new file mode 100644 index 000000000..234584bdb --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGF.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "workshopBundleBG.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGF.imageset/workshopBundleBG.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGF.imageset/workshopBundleBG.pdf new file mode 100644 index 000000000..fdb483af8 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBundleBGF.imageset/workshopBundleBG.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopOwnedIcon.imageset/Contents.json similarity index 100% rename from Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/Contents.json rename to Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopOwnedIcon.imageset/Contents.json diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/quickFilterOwned.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopOwnedIcon.imageset/quickFilterOwned.pdf similarity index 100% rename from Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/quickFilterOwned.pdf rename to Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopOwnedIcon.imageset/quickFilterOwned.pdf