From a98cdc0bd7024dd4b5cc1163fa51e2a84a617447 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 21 Jan 2026 21:10:45 +0900 Subject: [PATCH 1/9] =?UTF-8?q?style:=20=EA=B3=B5=EB=B0=A9=20=ED=82=A4?= =?UTF-8?q?=EB=A7=81=ED=83=AD=20BG=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Workshop/Views/Main/WorkshopView.swift | 4 ++-- .../workshopBG.imageset/WorkshopBack.pdf | Bin 24340 -> 0 bytes .../Contents.json | 2 +- .../workshopKeyringBGB.pdf} | Bin 4190 -> 4173 bytes .../Contents.json | 2 +- .../workshopKeyringBGF.pdf | Bin 0 -> 28131 bytes 6 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBG.imageset/WorkshopBack.pdf rename Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/{back.imageset => workshopKeyringBGB.imageset}/Contents.json (73%) rename Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/{back.imageset/back.pdf => workshopKeyringBGB.imageset/workshopKeyringBGB.pdf} (94%) rename Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/{workshopBG.imageset => workshopKeyringBGF.imageset}/Contents.json (73%) create mode 100644 Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopKeyringBGF.imageset/workshopKeyringBGF.pdf diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift index adf5ba17a..b4252be0e 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift @@ -63,7 +63,7 @@ struct WorkshopView: View { .allowsHitTesting(false) } .background( - Image(.back) + Image(.workshopKeyringBGB) .resizable() .scaledToFill() ) @@ -141,7 +141,7 @@ struct WorkshopView: View { } .padding(.top, 60) .background(alignment: .top) { - Image(.workshopBG) + Image(.workshopKeyringBGF) .resizable() .aspectRatio(contentMode: .fit) } diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBG.imageset/WorkshopBack.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBG.imageset/WorkshopBack.pdf deleted file mode 100644 index 94e825e5d40ae470d66cbcf0e8e23d3f698b068b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24340 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 delta 185 zcmX@Ba8F@_3lE=#iLs%Hf`Oi;shNeD@#K1L@y(e$>`c+d<}pTQ1~JB_#xX{QK(>L2 z0+3@8V`OX!lrRCZjexWvND)W^#4!M>0_gxMG6Ay9L2NW}BcMs9llSq-a%AVn7?_%7 r7j3@FC&efS)MylAU~Xh=VQOZnsi0wqA~0E8K##-RoJ&>J)!z*O$X6`N diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBG.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopKeyringBGF.imageset/Contents.json similarity index 73% rename from Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBG.imageset/Contents.json rename to Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopKeyringBGF.imageset/Contents.json index a54c85a93..e96ceaa6d 100644 --- a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopBG.imageset/Contents.json +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopKeyringBGF.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "WorkshopBack.pdf", + "filename" : "workshopKeyringBGF.pdf", "idiom" : "universal" } ], diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopKeyringBGF.imageset/workshopKeyringBGF.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/workshopKeyringBGF.imageset/workshopKeyringBGF.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a226b31d610a5eff94de1e290e3fb3c52a34b478 GIT binary patch literal 28131 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 literal 0 HcmV?d00001 From 14568bad95130df9ec521e5acf78e995d542211a Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 21 Jan 2026 21:19:36 +0900 Subject: [PATCH 2/9] =?UTF-8?q?style:=20Typography=20-=20nanum24EB=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift index 692c830e4..3d3422ce0 100644 --- a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift +++ b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift @@ -81,6 +81,7 @@ struct Typography { static let nanum10EB12 = Typography(font: .custom(.nanumExtraBold, size: 10), lineSpacing: 2) static let nanum18EB12 = Typography(font: .custom(.nanumExtraBold, size: 18), lineSpacing: 2) + static let nanum24EB = Typography(font: .custom(.nanumExtraBold, size: 24), lineSpacing: 0) static let nanum32EB = Typography(font: .custom(.nanumExtraBold, size: 32), lineSpacing: 2) // MARK: - Pretendard From 9da164583456e59f3b72223cd34a15cc2456b2e1 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 21 Jan 2026 21:20:22 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20WorkshopView=20-=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=ED=8B=80=20->=20=ED=82=A4=EB=A7=81/=EB=AD=89=EC=B9=98?= =?UTF-8?q?=20=ED=86=A0=EA=B8=80=20=EB=B2=84=ED=8A=BC=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Main/WorkshopTopBannerSection.swift | 32 ++++++++++++++----- .../Workshop/Views/Main/WorkshopView.swift | 1 + 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift index d542e7432..378b10de3 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopTopBannerSection.swift @@ -20,7 +20,7 @@ extension WorkshopView { .padding(.horizontal, 20) .frame(maxWidth: .infinity) } - + /// 스크롤 시 나타나는 상단 타이틀 바 var topTitleBar: some View { HStack { @@ -34,14 +34,30 @@ extension WorkshopView { .background(Color.white100) .opacity(viewModel.mainContentOffset - 80 < 70 ? 1 : 0) } - + /// 타이틀 뷰 var titleView: some View { - Text("공방") - .typography(.nanum32EB) - .frame(maxWidth: .infinity, alignment: .leading) + 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 { @@ -51,9 +67,9 @@ extension WorkshopView { Image(.myItem) .resizable() .scaledToFit() - + Spacer() - + Text("내 아이템") .typography(.suit17B) .foregroundColor(.black) diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift index b4252be0e..09e351a2d 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift @@ -17,6 +17,7 @@ struct WorkshopView: View { @State var viewModel: WorkshopViewModel @State private var hasInitialized = false @State private var isTabBarVisible = true + @State var workshopToggle: Bool = true let categories = ["템플릿", "카라비너", "이펙트", "배경"] From 0ba0aed57969e469561cbcbd34af84507a7ecbd3 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 21 Jan 2026 22:38:55 +0900 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20=EA=B3=B5=EB=B0=A9=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - keyring/ bundle 탭 분리를 위한 변경 --- .../Views/Bundle/WorkshopBundleBanner.swift | 81 +++ .../Views/Components/WorkshopComponents.swift | 638 ++++++++++++++++++ .../Keyring/WorkshopRecentTemplate.swift | 18 + ...ction.swift => WorkshopBundleBanner.swift} | 2 - .../Workshop/Views/Main/WorkshopPreview.swift | 396 +++++++++++ .../Workshop/Views/MyItemsView.swift | 350 ---------- 6 files changed, 1133 insertions(+), 352 deletions(-) create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleBanner.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift rename Keychy/Keychy/Presentation/Workshop/Views/Main/{WorkshopMakingKeyringSection.swift => WorkshopBundleBanner.swift} (97%) create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopPreview.swift delete mode 100644 Keychy/Keychy/Presentation/Workshop/Views/MyItemsView.swift diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleBanner.swift b/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleBanner.swift new file mode 100644 index 000000000..0a3213689 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleBanner.swift @@ -0,0 +1,81 @@ +// +// WorkshopMakingKeyringSection.swift +// Keychy +// +// Created by rundo on 11/24/25. +// + +import SwiftUI +import NukeUI + +// MARK: - MakingKeyring Section + +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) + ) + } + + // 썸네일 (로딩 중에만 GIF 위에 덮음 - 정지 상태) + if viewModel.isWorkshopBannerLoading, let thumbnailImage = viewModel.workshopThumbnailImage { + Image(uiImage: thumbnailImage) + .resizable() + .scaledToFit() + } + } + .frame(height: 120) + .padding(.bottom, 10) + } +} + + diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift new file mode 100644 index 000000000..e32a97d60 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopComponents.swift @@ -0,0 +1,638 @@ +// +// 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/Keyring/WorkshopRecentTemplate.swift b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift new file mode 100644 index 000000000..40e022112 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift @@ -0,0 +1,18 @@ +// +// WorkshopRecentTemplate.swift +// Keychy +// +// Created by 길지훈 on 1/21/26. +// + +import SwiftUI + +struct WorkshopRecentTemplate: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + WorkshopRecentTemplate() +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMakingKeyringSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopBundleBanner.swift similarity index 97% rename from Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMakingKeyringSection.swift rename to Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopBundleBanner.swift index abfd05b43..13aee8e97 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopMakingKeyringSection.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopBundleBanner.swift @@ -72,8 +72,6 @@ extension WorkshopView { .resizable() .scaledToFit() } - - // TODO: 네트워크 연결 끊김 처리 } .frame(height: 120) .padding(.bottom, 10) diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopPreview.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopPreview.swift new file mode 100644 index 000000000..e3424c069 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopPreview.swift @@ -0,0 +1,396 @@ +// +// WorkshopPreview.swift +// Keychy +// +// Created by rundo on 11/3/25. +// + +import SwiftUI +import Lottie +import FirebaseFirestore + +struct WorkshopPreview: View { + @Bindable var router: NavigationRouter + @Environment(UserManager.self) private var userManager + @State private var effectManager = EffectManager.shared + @State private var isParticleReady = false + + // 구매 관련 상태 + @State private var showPurchaseSheet = false + @State private var purchasePopupScale: CGFloat = 0.3 + @State private var showPurchasingLoading = false + @State private var showPurchaseSuccessAlert = false + @State private var showPurchaseFailAlert = false + + let viewModel: WorkshopViewModel + let item: any WorkshopItem + + /// 아이템 보유 여부 확인 + private var isOwned: Bool { + guard let user = userManager.currentUser, + let itemId = item.id else { return false } + + if item is KeyringTemplate { + return user.templates.contains(itemId) + } else if item is Background { + return user.backgrounds.contains(itemId) + } else if item is Carabiner { + return user.carabiners.contains(itemId) + } else if item is Particle { + return user.particleEffects.contains(itemId) + } else if item is Sound { + return user.soundEffects.contains(itemId) + } + return false + } + + var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 0) { + + Spacer() + + itemPreview + .adaptiveTopPaddingAlt() + + Spacer() + .frame(height: 10) + + HStack { + ItemDetailInfoSection(item: item) + + Spacer() + + // 사운드일 경우 재생 버튼 표시 + if item is Sound { + VStack { + Spacer() + effectPlayButton + } + } + } + .padding(.bottom, 40) + .frame(height: 120) + + actionButton + .adaptiveBottomPadding() + .padding(.bottom, getBottomPadding(40) == 0 ? 40 : 0) + } + .padding(.horizontal, 30) + + CustomNavigationBar { + BackToolbarButton { + TabBarManager.show() + router.pop() + } + } center: { + Spacer() + } trailing: { + Spacer() + } + } + .ignoresSafeArea() + .navigationBarBackButtonHidden(true) + .swipeBackGesture(enabled: true) + .onAppear { + TabBarManager.hide() + } + .blur(radius: (showPurchasingLoading || showPurchaseSuccessAlert) ? 10 : 0) + .animation(.easeInOut(duration: 0.3), value: (showPurchasingLoading || showPurchaseSuccessAlert)) + .withToast(position: .button) + .overlay { + ZStack(alignment: .center) { + // 구매 확인 팝업 + if showPurchaseSheet { + Color.black20 + .ignoresSafeArea() + .onTapGesture { + withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { + purchasePopupScale = 0.3 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + showPurchaseSheet = false + } + } + + PurchasePopup( + title: item.name, + myCoin: userManager.currentUser?.coin ?? 0, + price: item.workshopPrice, + scale: purchasePopupScale, + onConfirm: { + Task { + await handlePurchase() + } + } + ) + .padding(.horizontal, 40) + .padding(.bottom, 30) + } + + // 구매 중 로딩 + if showPurchasingLoading { + LoadingAlert(type: .short40, message: nil) + } + + // 구매 성공 알림 + if showPurchaseSuccessAlert { + KeychyAlert( + type: .checkmark, + message: "구매가 완료되었어요!", + isPresented: $showPurchaseSuccessAlert + ) + } + + // 구매 실패 알림 (코인 부족) + if showPurchaseFailAlert { + ZStack { + Color.black20 + .zIndex(99) + + LackPopup( + title: "코인이 부족해요", + message: "충전하러 갈까요?", + onCancel: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showPurchaseFailAlert = false + } + }, + onConfirm: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showPurchaseFailAlert = false + router.push(.coinCharge) + } + } + ) + .transition(.scale.combined(with: .opacity)) + .zIndex(100) + } + .ignoresSafeArea() + } + } + } + } +} + +// MARK: - Item Preview Section +extension WorkshopPreview { + private var itemPreview: some View { + VStack { + Spacer() + + // 파티클이 아닌 경우 이미지 표시 + if !(item is Particle) { + if item is Background { + ItemDetailImage(itemURL: getPreviewURL()) + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: getBottomPadding(5) == 0 ? 501 : 380) + .cornerRadius(20) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.gray50, lineWidth: 2) + ) + } else { + // 카라비너, 사운드: 1:1 비율 + ItemDetailImage(itemURL: getPreviewURL()) + .scaledToFit() + .aspectRatio(1, contentMode: .fit) + .cornerRadius(20) + } + } + + // 파티클 이펙트일 경우 무한 재생 (1:1 비율) + if let particle = item as? Particle, + let particleId = particle.id { + if isParticleReady { + infiniteParticleLottieView(particleId: particleId) + .scaledToFill() + .aspectRatio(1, contentMode: .fit) + .cornerRadius(20) + } else { + LoadingAlert(type: .short40, message: nil) + .task { + await ensureParticleReady(particle) + } + } + } + + Spacer() + } + .padding(.horizontal, 30) + } + + /// 파티클 다운로드 및 소유권 처리 + private func ensureParticleReady(_ particle: Particle) async { + guard let particleId = particle.id else { return } + + // 무료 파티클이고 아직 소유하지 않았다면 소유권 추가 + if particle.isFree && !(userManager.currentUser?.particleEffects.contains(particleId) ?? false) { + // playParticle을 통해 다운로드 및 소유권 처리 + await effectManager.playParticle(particle, userManager: userManager) + } else { + // 이미 캐시 또는 Bundle에 있으면 바로 준비 완료 + if effectManager.isInCache(particleId: particleId) || effectManager.isInBundle(particleId: particleId) { + isParticleReady = true + return + } + + // 다운로드 필요 + await effectManager.downloadParticle(particle, userManager: userManager) + } + + isParticleReady = true + } + + /// 파티클 무한 재생 뷰 + private func infiniteParticleLottieView(particleId: String) -> some View { + LottieView( + name: particleId, + loopMode: .loop, // 무한 재생 + speed: 1.0 + ) + } + + /// 사운드 재생 버튼 + private var effectPlayButton: some View { + let itemId = item.id ?? "" + let isDownloading = effectManager.downloadingItemIds.contains(itemId) + let progress = effectManager.downloadProgress[itemId] ?? 0.0 + + return Button { + Task { + if let sound = item as? Sound { + await effectManager.playSound(sound, userManager: userManager) + } + } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(.black100) + .frame(width: 38, height: 38) + + if isDownloading { + CircularProgressView(progress: progress) + .frame(width: 20, height: 20) + } else { + Image(.whitePolygon) + .resizable() + .scaledToFit() + .frame(width: 12, height: 12) + } + } + } + .disabled(isDownloading) + } + + /// 프리뷰 이미지 URL 가져오기 + private func getPreviewURL() -> String { + if let template = item as? KeyringTemplate { + return template.previewURL + } else if let background = item as? Background { + return background.backgroundImage + } else if let carabiner = item as? Carabiner { + return carabiner.carabinerImage[0] + } else if let particle = item as? Particle { + return particle.thumbnail + } else if let sound = item as? Sound { + return sound.thumbnail + } + return item.thumbnailURL + } +} + +// MARK: - Action Button Section +extension WorkshopPreview { + private var actionButton: some View { + WorkshopItemActionButton( + item: item, + isOwned: isOwned, + onPurchase: { + // 네트워크 체크 + guard NetworkManager.shared.isConnected else { + ToastManager.shared.show() + return + } + + showPurchaseSheet = true + withAnimation(.spring(response: 0.6, dampingFraction: 0.5)) { + purchasePopupScale = 1.0 + } + } + ) + } + + /// 구매 처리 + private func handlePurchase() async { + // 네트워크 체크 + guard NetworkManager.shared.isConnected else { + await MainActor.run { + withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { + purchasePopupScale = 0.3 + } + } + + try? await Task.sleep(for: .seconds(0.2)) + + await MainActor.run { + showPurchaseSheet = false + } + + try? await Task.sleep(for: .seconds(0.1)) + + await MainActor.run { + ToastManager.shared.show() + } + return + } + + // 팝업 닫기 애니메이션 + await MainActor.run { + withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { + purchasePopupScale = 0.3 + } + } + + try? await Task.sleep(nanoseconds: 200_000_000) + + await MainActor.run { + showPurchaseSheet = false + } + + try? await Task.sleep(nanoseconds: 100_000_000) + + // 로딩 시작 + await MainActor.run { + showPurchasingLoading = true + } + + // ItemPurchaseManager를 통해 구매 처리 + let result = await ItemPurchaseManager.shared.purchaseWorkshopItem(item, userManager: userManager) + + // 로딩 종료 + await MainActor.run { + showPurchasingLoading = false + } + + try? await Task.sleep(nanoseconds: 100_000_000) + + switch result { + case .success: + // 성공 시 성공 알림 표시 + showPurchaseSuccessAlert = true + + case .insufficientCoins: + // 코인 부족 시 실패 알림 표시 + await MainActor.run { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showPurchaseFailAlert = true + } + } + + case .failed(let message): + // 기타 실패 시 에러 출력 + print("구매 실패: \(message)") + } + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/MyItemsView.swift b/Keychy/Keychy/Presentation/Workshop/Views/MyItemsView.swift deleted file mode 100644 index db4945fd7..000000000 --- a/Keychy/Keychy/Presentation/Workshop/Views/MyItemsView.swift +++ /dev/null @@ -1,350 +0,0 @@ -// -// MyItemsView.swift -// Keychy -// -// Created by rundo on 10/30/25. -// - -import SwiftUI - -struct MyItemsView: View { - - @Bindable var router: NavigationRouter - @Environment(UserManager.self) private var userManager - @State private var viewModel: WorkshopViewModel - @State private var hasInitialized = false - - private let categories = ["템플릿", "카라비너", "이펙트", "배경"] - - /// 초기화 시점에는 Environment 접근 불가하므로 shared 인스턴스로 임시 생성 - /// 실제 userManager는 .task에서 교체됨 - init(router: NavigationRouter) { - self.router = router - _viewModel = State(initialValue: WorkshopViewModel(userManager: UserManager.shared)) - } - - var body: some View { - ZStack(alignment: .top) { - // 배경 - Color.white - .ignoresSafeArea() - - // 메인 콘텐츠 (전체 화면) - if viewModel.hasNetworkError { - NoInternetView(topPadding: getSafeAreaTop() + 120, onRetry: { - Task { - await viewModel.retryFetchAllData() - } - }) - .ignoresSafeArea() - } else { - VStack(spacing: 0) { - // 메인 콘텐츠 (그리드) - mainContentSection - .background( - GeometryReader { geo in - let minY = geo.frame(in: .global).minY - Color.clear - .onAppear { - viewModel.mainContentOffset = minY - } - .onChange(of: minY) { oldValue, newValue in - viewModel.mainContentOffset = newValue - } - } - ) - } - .pullToRefresh(topPadding: 60) { - await viewModel.retryFetchAllData() - } - .adaptiveTopPaddingAlt() - .padding(.top, 20) - } - - // 헤더 오버레이 (항상 표시) - VStack(spacing: 0) { - VStack(spacing: 0) { - CategoryTabBar( - categories: categories, - selectedCategory: $viewModel.selectedCategory - ) - - filterBar - .padding(.bottom, 10) - } - .background(Color.white) - - Spacer() - } - .adaptiveTopPaddingAlt() - .padding(.top, 20) - - customNavigationBar - } - .ignoresSafeArea() - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden() - .swipeBackGesture(enabled: true) - .task { - // 네트워크 체크 - guard NetworkManager.shared.isConnected else { - viewModel.hasNetworkError = true - return - } - - // 최초 한 번만 초기화 - if !hasInitialized { - viewModel = WorkshopViewModel(userManager: userManager) - hasInitialized = true - - await viewModel.fetchAllData() - } - } - .onChange(of: viewModel.selectedCategory) { oldValue, newValue in - viewModel.resetFilters() - } - .sheet(isPresented: $viewModel.showFilterSheet) { - sortSheet - } - } - - /// 필터바 - private var filterBar: some View { - WorkshopFilterBar(viewModel: $viewModel) - .padding(.horizontal, 20) - } - - // MARK: - Main Content Section - - /// 메인 콘텐츠 영역 (카테고리별 그리드) - private var mainContentSection: some View { - VStack { - if viewModel.isLoading { - loadingView - } else { - categoryContent - } - } - } - - /// 로딩 뷰 - private var loadingView: some View { - VStack(spacing: 12) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .purple)) - .scaleEffect(1.5) - } - .frame(maxWidth: .infinity, minHeight: 300) - .padding(.top, 50) - } - - /// 카테고리별 콘텐츠 - private var categoryContent: some View { - Group { - switch viewModel.selectedCategory { - case "템플릿": - WorkshopGridHelpers.itemGridView( - items: filteredOwnedTemplates, - isOwnedCheck: { _ in false }, - router: router, - viewModel: viewModel, - emptyView: emptyContentView - ) - case "배경": - WorkshopGridHelpers.itemGridView( - items: filteredOwnedBackgrounds, - isOwnedCheck: { _ in false }, - router: router, - viewModel: viewModel, - emptyView: emptyContentView - ) - case "카라비너": - WorkshopGridHelpers.itemGridView( - items: filteredOwnedCarabiners, - isOwnedCheck: { _ in false }, - router: router, - viewModel: viewModel, - emptyView: emptyContentView - ) - case "이펙트": - WorkshopGridHelpers.effectGridView( - items: filteredOwnedEffects, - isSoundOwned: { _ in false }, - isParticleOwned: { _ in false }, - router: router, - viewModel: viewModel, - emptyView: emptyContentView - ) - default: - emptyContentView - } - } - } - - /// 열쇠 충전 버튼 - private var chargeCoinBtn: some View { - Button { - router.push(.coinCharge) - } label: { - HStack(spacing: 0) { - Image(.myCoinMini) - .resizable() - .scaledToFill() - .frame(width: 36) - - Text("\((userManager.currentUser?.coin ?? 0).formatted())") - .typography(.nanum16EB) - .foregroundColor(.black) - } - .padding(.horizontal, 12) - .padding(.vertical, 4) - } - .frame(height: 44) - .buttonStyle(.plain) - .glassEffect(.regular.interactive(), in: .capsule) - } - - /// 빈 콘텐츠 뷰 - private var emptyContentView: some View { - VStack { - - Spacer() - .frame(height: 280) - - Image(.emptyViewIcon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 124) - - Text("보유한 아이템이 없어요") - .typography(.suit15R) - .padding(.leading, 10) - } - } - - // MARK: - Filtering Logic - - /// 필터링된 보유 템플릿 목록 - private var filteredOwnedTemplates: [KeyringTemplate] { - var result = viewModel.ownedTemplates - - if let filter = viewModel.selectedTemplateFilter { - switch filter { - case .image: - result = result.filter { $0.tags.contains("이미지") } - case .text: - result = result.filter { $0.tags.contains("텍스트") } - case .drawing: - result = result.filter { $0.tags.contains("드로잉") } - } - } - - return applySorting(to: result) - } - - private var filteredOwnedBackgrounds: [Background] { - var result = viewModel.ownedBackgrounds - - if let filter = viewModel.selectedCommonFilter { - result = result.filter { $0.tags.contains(filter) } - } - - return applySorting(to: result) - } - - private var filteredOwnedCarabiners: [Carabiner] { - var result = viewModel.ownedCarabiners - - if let filter = viewModel.selectedCommonFilter { - result = result.filter { $0.tags.contains(filter) } - } - - return applySorting(to: result) - } - - /// 필터링된 보유 이펙트 목록 - private var filteredOwnedEffects: [any WorkshopItem] { - var result: [any WorkshopItem] = [] - - switch viewModel.selectedEffectFilter { - case .sound: - result = viewModel.ownedSounds - case .particle: - result = viewModel.ownedParticles - case nil: - // 필터가 없으면 사운드와 파티클 모두 표시 - result = (viewModel.ownedSounds as [any WorkshopItem]) + (viewModel.ownedParticles as [any WorkshopItem]) - } - - // any WorkshopItem 배열에 대한 정렬 - return result.sorted { item1, item2 in - switch viewModel.sortOrder { - case "최신순": - return item1.createdAt > item2.createdAt - case "인기순": - return item1.useCount > item2.useCount - default: - return false - } - } - } - - /// 정렬 적용 - private func applySorting(to items: [T]) -> [T] { - var sortedItems = items - switch viewModel.sortOrder { - case "최신순": - sortedItems.sort { $0.createdAt > $1.createdAt } - case "인기순": - sortedItems.sort { $0.useCount > $1.useCount } - default: - break - } - return sortedItems - } - - /// 정렬 선택 시트 - private var sortSheet: some View { - WorkshopSortSheet( - showSheet: $viewModel.showFilterSheet, - sortOrder: $viewModel.sortOrder - ) - } - - /// 커스텀 네비 - private var customNavigationBar: some View { - ZStack(alignment: .topTrailing) { - // 네비바 (trailing 없이) - CustomNavigationBar { - BackToolbarButton { - TabBarManager.show() - router.pop() - } - } center: { - Text("내 아이템") - .typography(.notosans17M) - } trailing: { - Spacer() - .frame(width: 44, height: 44) - } - - // 코인 버튼 (오른쪽 상단에 별도 배치) - chargeCoinBtn - .padding(.trailing, 16) - .padding(.top, getSafeAreaTop()) - } - } - -} - -/// Safe Area Top 계산 -private func getSafeAreaTop() -> CGFloat { - guard let window = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first?.windows - .first(where: { $0.isKeyWindow }) else { - return 0 - } - return window.safeAreaInsets.top -} - From f64a37d1bcecdad5f04aba8e3289a7f4bd787d22 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 21 Jan 2026 22:40:13 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore:=20=ED=8C=8C=EC=9D=BC=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/WorkshopComponents.swift | 638 ------------------ .../Workshop/Views/Main/MyItemsView.swift | 350 ++++++++++ .../Views/Main/WorkshopBundleBanner.swift | 81 --- .../Workshop/Views/WorkshopPreview.swift | 396 ----------- 4 files changed, 350 insertions(+), 1115 deletions(-) delete mode 100644 Keychy/Keychy/Presentation/Workshop/Components/WorkshopComponents.swift create mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Main/MyItemsView.swift delete mode 100644 Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopBundleBanner.swift delete mode 100644 Keychy/Keychy/Presentation/Workshop/Views/WorkshopPreview.swift diff --git a/Keychy/Keychy/Presentation/Workshop/Components/WorkshopComponents.swift b/Keychy/Keychy/Presentation/Workshop/Components/WorkshopComponents.swift deleted file mode 100644 index e32a97d60..000000000 --- a/Keychy/Keychy/Presentation/Workshop/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/Main/MyItemsView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/MyItemsView.swift new file mode 100644 index 000000000..db4945fd7 --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/MyItemsView.swift @@ -0,0 +1,350 @@ +// +// MyItemsView.swift +// Keychy +// +// Created by rundo on 10/30/25. +// + +import SwiftUI + +struct MyItemsView: View { + + @Bindable var router: NavigationRouter + @Environment(UserManager.self) private var userManager + @State private var viewModel: WorkshopViewModel + @State private var hasInitialized = false + + private let categories = ["템플릿", "카라비너", "이펙트", "배경"] + + /// 초기화 시점에는 Environment 접근 불가하므로 shared 인스턴스로 임시 생성 + /// 실제 userManager는 .task에서 교체됨 + init(router: NavigationRouter) { + self.router = router + _viewModel = State(initialValue: WorkshopViewModel(userManager: UserManager.shared)) + } + + var body: some View { + ZStack(alignment: .top) { + // 배경 + Color.white + .ignoresSafeArea() + + // 메인 콘텐츠 (전체 화면) + if viewModel.hasNetworkError { + NoInternetView(topPadding: getSafeAreaTop() + 120, onRetry: { + Task { + await viewModel.retryFetchAllData() + } + }) + .ignoresSafeArea() + } else { + VStack(spacing: 0) { + // 메인 콘텐츠 (그리드) + mainContentSection + .background( + GeometryReader { geo in + let minY = geo.frame(in: .global).minY + Color.clear + .onAppear { + viewModel.mainContentOffset = minY + } + .onChange(of: minY) { oldValue, newValue in + viewModel.mainContentOffset = newValue + } + } + ) + } + .pullToRefresh(topPadding: 60) { + await viewModel.retryFetchAllData() + } + .adaptiveTopPaddingAlt() + .padding(.top, 20) + } + + // 헤더 오버레이 (항상 표시) + VStack(spacing: 0) { + VStack(spacing: 0) { + CategoryTabBar( + categories: categories, + selectedCategory: $viewModel.selectedCategory + ) + + filterBar + .padding(.bottom, 10) + } + .background(Color.white) + + Spacer() + } + .adaptiveTopPaddingAlt() + .padding(.top, 20) + + customNavigationBar + } + .ignoresSafeArea() + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() + .swipeBackGesture(enabled: true) + .task { + // 네트워크 체크 + guard NetworkManager.shared.isConnected else { + viewModel.hasNetworkError = true + return + } + + // 최초 한 번만 초기화 + if !hasInitialized { + viewModel = WorkshopViewModel(userManager: userManager) + hasInitialized = true + + await viewModel.fetchAllData() + } + } + .onChange(of: viewModel.selectedCategory) { oldValue, newValue in + viewModel.resetFilters() + } + .sheet(isPresented: $viewModel.showFilterSheet) { + sortSheet + } + } + + /// 필터바 + private var filterBar: some View { + WorkshopFilterBar(viewModel: $viewModel) + .padding(.horizontal, 20) + } + + // MARK: - Main Content Section + + /// 메인 콘텐츠 영역 (카테고리별 그리드) + private var mainContentSection: some View { + VStack { + if viewModel.isLoading { + loadingView + } else { + categoryContent + } + } + } + + /// 로딩 뷰 + private var loadingView: some View { + VStack(spacing: 12) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .purple)) + .scaleEffect(1.5) + } + .frame(maxWidth: .infinity, minHeight: 300) + .padding(.top, 50) + } + + /// 카테고리별 콘텐츠 + private var categoryContent: some View { + Group { + switch viewModel.selectedCategory { + case "템플릿": + WorkshopGridHelpers.itemGridView( + items: filteredOwnedTemplates, + isOwnedCheck: { _ in false }, + router: router, + viewModel: viewModel, + emptyView: emptyContentView + ) + case "배경": + WorkshopGridHelpers.itemGridView( + items: filteredOwnedBackgrounds, + isOwnedCheck: { _ in false }, + router: router, + viewModel: viewModel, + emptyView: emptyContentView + ) + case "카라비너": + WorkshopGridHelpers.itemGridView( + items: filteredOwnedCarabiners, + isOwnedCheck: { _ in false }, + router: router, + viewModel: viewModel, + emptyView: emptyContentView + ) + case "이펙트": + WorkshopGridHelpers.effectGridView( + items: filteredOwnedEffects, + isSoundOwned: { _ in false }, + isParticleOwned: { _ in false }, + router: router, + viewModel: viewModel, + emptyView: emptyContentView + ) + default: + emptyContentView + } + } + } + + /// 열쇠 충전 버튼 + private var chargeCoinBtn: some View { + Button { + router.push(.coinCharge) + } label: { + HStack(spacing: 0) { + Image(.myCoinMini) + .resizable() + .scaledToFill() + .frame(width: 36) + + Text("\((userManager.currentUser?.coin ?? 0).formatted())") + .typography(.nanum16EB) + .foregroundColor(.black) + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + } + .frame(height: 44) + .buttonStyle(.plain) + .glassEffect(.regular.interactive(), in: .capsule) + } + + /// 빈 콘텐츠 뷰 + private var emptyContentView: some View { + VStack { + + Spacer() + .frame(height: 280) + + Image(.emptyViewIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 124) + + Text("보유한 아이템이 없어요") + .typography(.suit15R) + .padding(.leading, 10) + } + } + + // MARK: - Filtering Logic + + /// 필터링된 보유 템플릿 목록 + private var filteredOwnedTemplates: [KeyringTemplate] { + var result = viewModel.ownedTemplates + + if let filter = viewModel.selectedTemplateFilter { + switch filter { + case .image: + result = result.filter { $0.tags.contains("이미지") } + case .text: + result = result.filter { $0.tags.contains("텍스트") } + case .drawing: + result = result.filter { $0.tags.contains("드로잉") } + } + } + + return applySorting(to: result) + } + + private var filteredOwnedBackgrounds: [Background] { + var result = viewModel.ownedBackgrounds + + if let filter = viewModel.selectedCommonFilter { + result = result.filter { $0.tags.contains(filter) } + } + + return applySorting(to: result) + } + + private var filteredOwnedCarabiners: [Carabiner] { + var result = viewModel.ownedCarabiners + + if let filter = viewModel.selectedCommonFilter { + result = result.filter { $0.tags.contains(filter) } + } + + return applySorting(to: result) + } + + /// 필터링된 보유 이펙트 목록 + private var filteredOwnedEffects: [any WorkshopItem] { + var result: [any WorkshopItem] = [] + + switch viewModel.selectedEffectFilter { + case .sound: + result = viewModel.ownedSounds + case .particle: + result = viewModel.ownedParticles + case nil: + // 필터가 없으면 사운드와 파티클 모두 표시 + result = (viewModel.ownedSounds as [any WorkshopItem]) + (viewModel.ownedParticles as [any WorkshopItem]) + } + + // any WorkshopItem 배열에 대한 정렬 + return result.sorted { item1, item2 in + switch viewModel.sortOrder { + case "최신순": + return item1.createdAt > item2.createdAt + case "인기순": + return item1.useCount > item2.useCount + default: + return false + } + } + } + + /// 정렬 적용 + private func applySorting(to items: [T]) -> [T] { + var sortedItems = items + switch viewModel.sortOrder { + case "최신순": + sortedItems.sort { $0.createdAt > $1.createdAt } + case "인기순": + sortedItems.sort { $0.useCount > $1.useCount } + default: + break + } + return sortedItems + } + + /// 정렬 선택 시트 + private var sortSheet: some View { + WorkshopSortSheet( + showSheet: $viewModel.showFilterSheet, + sortOrder: $viewModel.sortOrder + ) + } + + /// 커스텀 네비 + private var customNavigationBar: some View { + ZStack(alignment: .topTrailing) { + // 네비바 (trailing 없이) + CustomNavigationBar { + BackToolbarButton { + TabBarManager.show() + router.pop() + } + } center: { + Text("내 아이템") + .typography(.notosans17M) + } trailing: { + Spacer() + .frame(width: 44, height: 44) + } + + // 코인 버튼 (오른쪽 상단에 별도 배치) + chargeCoinBtn + .padding(.trailing, 16) + .padding(.top, getSafeAreaTop()) + } + } + +} + +/// Safe Area Top 계산 +private func getSafeAreaTop() -> CGFloat { + guard let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first?.windows + .first(where: { $0.isKeyWindow }) else { + return 0 + } + return window.safeAreaInsets.top +} + diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopBundleBanner.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopBundleBanner.swift deleted file mode 100644 index 13aee8e97..000000000 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopBundleBanner.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// WorkshopMakingKeyringSection.swift -// Keychy -// -// Created by rundo on 11/24/25. -// - -import SwiftUI -import NukeUI - -// MARK: - MakingKeyring Section - -extension WorkshopView { - var makingKeyringSection: 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) - ) - } - - // 썸네일 (로딩 중에만 GIF 위에 덮음 - 정지 상태) - if viewModel.isWorkshopBannerLoading, let thumbnailImage = viewModel.workshopThumbnailImage { - Image(uiImage: thumbnailImage) - .resizable() - .scaledToFit() - } - } - .frame(height: 120) - .padding(.bottom, 10) - } -} - - diff --git a/Keychy/Keychy/Presentation/Workshop/Views/WorkshopPreview.swift b/Keychy/Keychy/Presentation/Workshop/Views/WorkshopPreview.swift deleted file mode 100644 index e3424c069..000000000 --- a/Keychy/Keychy/Presentation/Workshop/Views/WorkshopPreview.swift +++ /dev/null @@ -1,396 +0,0 @@ -// -// WorkshopPreview.swift -// Keychy -// -// Created by rundo on 11/3/25. -// - -import SwiftUI -import Lottie -import FirebaseFirestore - -struct WorkshopPreview: View { - @Bindable var router: NavigationRouter - @Environment(UserManager.self) private var userManager - @State private var effectManager = EffectManager.shared - @State private var isParticleReady = false - - // 구매 관련 상태 - @State private var showPurchaseSheet = false - @State private var purchasePopupScale: CGFloat = 0.3 - @State private var showPurchasingLoading = false - @State private var showPurchaseSuccessAlert = false - @State private var showPurchaseFailAlert = false - - let viewModel: WorkshopViewModel - let item: any WorkshopItem - - /// 아이템 보유 여부 확인 - private var isOwned: Bool { - guard let user = userManager.currentUser, - let itemId = item.id else { return false } - - if item is KeyringTemplate { - return user.templates.contains(itemId) - } else if item is Background { - return user.backgrounds.contains(itemId) - } else if item is Carabiner { - return user.carabiners.contains(itemId) - } else if item is Particle { - return user.particleEffects.contains(itemId) - } else if item is Sound { - return user.soundEffects.contains(itemId) - } - return false - } - - var body: some View { - ZStack { - VStack(alignment: .leading, spacing: 0) { - - Spacer() - - itemPreview - .adaptiveTopPaddingAlt() - - Spacer() - .frame(height: 10) - - HStack { - ItemDetailInfoSection(item: item) - - Spacer() - - // 사운드일 경우 재생 버튼 표시 - if item is Sound { - VStack { - Spacer() - effectPlayButton - } - } - } - .padding(.bottom, 40) - .frame(height: 120) - - actionButton - .adaptiveBottomPadding() - .padding(.bottom, getBottomPadding(40) == 0 ? 40 : 0) - } - .padding(.horizontal, 30) - - CustomNavigationBar { - BackToolbarButton { - TabBarManager.show() - router.pop() - } - } center: { - Spacer() - } trailing: { - Spacer() - } - } - .ignoresSafeArea() - .navigationBarBackButtonHidden(true) - .swipeBackGesture(enabled: true) - .onAppear { - TabBarManager.hide() - } - .blur(radius: (showPurchasingLoading || showPurchaseSuccessAlert) ? 10 : 0) - .animation(.easeInOut(duration: 0.3), value: (showPurchasingLoading || showPurchaseSuccessAlert)) - .withToast(position: .button) - .overlay { - ZStack(alignment: .center) { - // 구매 확인 팝업 - if showPurchaseSheet { - Color.black20 - .ignoresSafeArea() - .onTapGesture { - withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { - purchasePopupScale = 0.3 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - showPurchaseSheet = false - } - } - - PurchasePopup( - title: item.name, - myCoin: userManager.currentUser?.coin ?? 0, - price: item.workshopPrice, - scale: purchasePopupScale, - onConfirm: { - Task { - await handlePurchase() - } - } - ) - .padding(.horizontal, 40) - .padding(.bottom, 30) - } - - // 구매 중 로딩 - if showPurchasingLoading { - LoadingAlert(type: .short40, message: nil) - } - - // 구매 성공 알림 - if showPurchaseSuccessAlert { - KeychyAlert( - type: .checkmark, - message: "구매가 완료되었어요!", - isPresented: $showPurchaseSuccessAlert - ) - } - - // 구매 실패 알림 (코인 부족) - if showPurchaseFailAlert { - ZStack { - Color.black20 - .zIndex(99) - - LackPopup( - title: "코인이 부족해요", - message: "충전하러 갈까요?", - onCancel: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showPurchaseFailAlert = false - } - }, - onConfirm: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showPurchaseFailAlert = false - router.push(.coinCharge) - } - } - ) - .transition(.scale.combined(with: .opacity)) - .zIndex(100) - } - .ignoresSafeArea() - } - } - } - } -} - -// MARK: - Item Preview Section -extension WorkshopPreview { - private var itemPreview: some View { - VStack { - Spacer() - - // 파티클이 아닌 경우 이미지 표시 - if !(item is Particle) { - if item is Background { - ItemDetailImage(itemURL: getPreviewURL()) - .scaledToFill() - .frame(maxWidth: .infinity, maxHeight: getBottomPadding(5) == 0 ? 501 : 380) - .cornerRadius(20) - .overlay( - RoundedRectangle(cornerRadius: 20) - .stroke(Color.gray50, lineWidth: 2) - ) - } else { - // 카라비너, 사운드: 1:1 비율 - ItemDetailImage(itemURL: getPreviewURL()) - .scaledToFit() - .aspectRatio(1, contentMode: .fit) - .cornerRadius(20) - } - } - - // 파티클 이펙트일 경우 무한 재생 (1:1 비율) - if let particle = item as? Particle, - let particleId = particle.id { - if isParticleReady { - infiniteParticleLottieView(particleId: particleId) - .scaledToFill() - .aspectRatio(1, contentMode: .fit) - .cornerRadius(20) - } else { - LoadingAlert(type: .short40, message: nil) - .task { - await ensureParticleReady(particle) - } - } - } - - Spacer() - } - .padding(.horizontal, 30) - } - - /// 파티클 다운로드 및 소유권 처리 - private func ensureParticleReady(_ particle: Particle) async { - guard let particleId = particle.id else { return } - - // 무료 파티클이고 아직 소유하지 않았다면 소유권 추가 - if particle.isFree && !(userManager.currentUser?.particleEffects.contains(particleId) ?? false) { - // playParticle을 통해 다운로드 및 소유권 처리 - await effectManager.playParticle(particle, userManager: userManager) - } else { - // 이미 캐시 또는 Bundle에 있으면 바로 준비 완료 - if effectManager.isInCache(particleId: particleId) || effectManager.isInBundle(particleId: particleId) { - isParticleReady = true - return - } - - // 다운로드 필요 - await effectManager.downloadParticle(particle, userManager: userManager) - } - - isParticleReady = true - } - - /// 파티클 무한 재생 뷰 - private func infiniteParticleLottieView(particleId: String) -> some View { - LottieView( - name: particleId, - loopMode: .loop, // 무한 재생 - speed: 1.0 - ) - } - - /// 사운드 재생 버튼 - private var effectPlayButton: some View { - let itemId = item.id ?? "" - let isDownloading = effectManager.downloadingItemIds.contains(itemId) - let progress = effectManager.downloadProgress[itemId] ?? 0.0 - - return Button { - Task { - if let sound = item as? Sound { - await effectManager.playSound(sound, userManager: userManager) - } - } - } label: { - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(.black100) - .frame(width: 38, height: 38) - - if isDownloading { - CircularProgressView(progress: progress) - .frame(width: 20, height: 20) - } else { - Image(.whitePolygon) - .resizable() - .scaledToFit() - .frame(width: 12, height: 12) - } - } - } - .disabled(isDownloading) - } - - /// 프리뷰 이미지 URL 가져오기 - private func getPreviewURL() -> String { - if let template = item as? KeyringTemplate { - return template.previewURL - } else if let background = item as? Background { - return background.backgroundImage - } else if let carabiner = item as? Carabiner { - return carabiner.carabinerImage[0] - } else if let particle = item as? Particle { - return particle.thumbnail - } else if let sound = item as? Sound { - return sound.thumbnail - } - return item.thumbnailURL - } -} - -// MARK: - Action Button Section -extension WorkshopPreview { - private var actionButton: some View { - WorkshopItemActionButton( - item: item, - isOwned: isOwned, - onPurchase: { - // 네트워크 체크 - guard NetworkManager.shared.isConnected else { - ToastManager.shared.show() - return - } - - showPurchaseSheet = true - withAnimation(.spring(response: 0.6, dampingFraction: 0.5)) { - purchasePopupScale = 1.0 - } - } - ) - } - - /// 구매 처리 - private func handlePurchase() async { - // 네트워크 체크 - guard NetworkManager.shared.isConnected else { - await MainActor.run { - withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { - purchasePopupScale = 0.3 - } - } - - try? await Task.sleep(for: .seconds(0.2)) - - await MainActor.run { - showPurchaseSheet = false - } - - try? await Task.sleep(for: .seconds(0.1)) - - await MainActor.run { - ToastManager.shared.show() - } - return - } - - // 팝업 닫기 애니메이션 - await MainActor.run { - withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { - purchasePopupScale = 0.3 - } - } - - try? await Task.sleep(nanoseconds: 200_000_000) - - await MainActor.run { - showPurchaseSheet = false - } - - try? await Task.sleep(nanoseconds: 100_000_000) - - // 로딩 시작 - await MainActor.run { - showPurchasingLoading = true - } - - // ItemPurchaseManager를 통해 구매 처리 - let result = await ItemPurchaseManager.shared.purchaseWorkshopItem(item, userManager: userManager) - - // 로딩 종료 - await MainActor.run { - showPurchasingLoading = false - } - - try? await Task.sleep(nanoseconds: 100_000_000) - - switch result { - case .success: - // 성공 시 성공 알림 표시 - showPurchaseSuccessAlert = true - - case .insufficientCoins: - // 코인 부족 시 실패 알림 표시 - await MainActor.run { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showPurchaseFailAlert = true - } - } - - case .failed(let message): - // 기타 실패 시 에러 출력 - print("구매 실패: \(message)") - } - } -} From 0802cd03053ec8d49377cb8736354355268659b2 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 21 Jan 2026 22:40:34 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20KeychUser=20-=20recentTemplates=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy/CommonModels/User/KeychyUser.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Keychy/Keychy/CommonModels/User/KeychyUser.swift b/Keychy/Keychy/CommonModels/User/KeychyUser.swift index 252a1e9b0..3f95959be 100644 --- a/Keychy/Keychy/CommonModels/User/KeychyUser.swift +++ b/Keychy/Keychy/CommonModels/User/KeychyUser.swift @@ -27,6 +27,7 @@ struct KeychyUser: Identifiable { var carabiners: [String] var tags: [String] var keyrings: [String] + var recentTemplates: [String] // 최근 사용 템플릿 ID (최대 5개, 최신순) var termsAgreed: Bool // 필수 약관 동의 여부 var marketingAgreed: Bool // 마케팅 수신 동의 여부 @@ -48,6 +49,7 @@ struct KeychyUser: Identifiable { "carabiners": carabiners, "tags": tags, "keyrings": keyrings, + "recentTemplates": recentTemplates, "termsAgreed": termsAgreed, "marketingAgreed": marketingAgreed ] @@ -77,6 +79,7 @@ struct KeychyUser: Identifiable { self.carabiners = data["carabiners"] as? [String] ?? [] self.tags = data["tags"] as? [String] ?? [] self.keyrings = data["keyrings"] as? [String] ?? [] + self.recentTemplates = data["recentTemplates"] as? [String] ?? [] self.termsAgreed = data["termsAgreed"] as? Bool ?? false self.marketingAgreed = data["marketingAgreed"] as? Bool ?? false } @@ -99,6 +102,7 @@ struct KeychyUser: Identifiable { self.carabiners = [] self.tags = [] self.keyrings = [] + self.recentTemplates = [] self.termsAgreed = false self.marketingAgreed = false } From a95b72f7f347b1952b13bd6b547411eb10793b00 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 21 Jan 2026 22:50:54 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20KeyringInfoInputView+FirebaseSave?= =?UTF-8?q?=20-=20=EC=B5=9C=EA=B7=BC=20=EC=82=AC=EC=9A=A9=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../KeyringInfoInputView+FirebaseSave.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+FirebaseSave.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+FirebaseSave.swift index 69dba80fc..b25439bf5 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+FirebaseSave.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+FirebaseSave.swift @@ -194,6 +194,12 @@ extension KeyringInfoInputView { soundId: soundId, particleId: particleId ) + + // 최근 사용 템플릿 업데이트 + if !selectedTemplate.isEmpty { + self.updateRecentTemplates(uid: uid, templateId: selectedTemplate) + } + completion(true, keyringId) } else { completion(false, nil) @@ -273,6 +279,44 @@ extension KeyringInfoInputView { } } } + + // MARK: - 최근 사용 템플릿 업데이트 + /// 새 템플릿을 맨 앞에 추가하고, 중복 제거 후 최대 10개 유지 + private func updateRecentTemplates(uid: String, templateId: String) { + let userRef = db.collection("User").document(uid) + + userRef.getDocument { snapshot, error in + guard let data = snapshot?.data(), + error == nil else { + print("[RecentTemplates] 문서 읽기 실패: \(error?.localizedDescription ?? "")") + return + } + + var recentTemplates = data["recentTemplates"] as? [String] ?? [] + + // 1. 이미 있으면 제거 (중복 방지) + recentTemplates.removeAll { $0 == templateId } + + // 2. 맨 앞에 추가 + recentTemplates.insert(templateId, at: 0) + + // 3. 최대 5개 유지 + if recentTemplates.count > 5 { + recentTemplates = Array(recentTemplates.prefix(5)) + } + + // 4. Firebase 업데이트 + userRef.updateData([ + "recentTemplates": recentTemplates + ]) { error in + if let error = error { + print("[RecentTemplates] 업데이트 실패: \(error.localizedDescription)") + } else { + print("[RecentTemplates] 업데이트 성공: \(templateId)") + } + } + } + } // MARK: - 위젯용 이미지 캡처 및 캐싱 private func captureAndCacheKeyring( From e07e29b4948c5b4a2f0f6ecc777fc382b0659712 Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 21 Jan 2026 22:51:57 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20WorkshopView=20-=20=EC=B5=9C?= =?UTF-8?q?=EA=B7=BC=EC=82=AC=EC=9A=A9=20=ED=85=9C=ED=94=8C=EB=A6=BF=20fet?= =?UTF-8?q?ch=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 40 ++++++++++++++----- .../ViewModels/WorkshopViewModel.swift | 9 +++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 0cc4926a7..83242c0e5 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -120,7 +120,7 @@ 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 /* WorkshopMakingKeyringSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C47331A2F1FA2AB005D2376 /* WorkshopMakingKeyringSection.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 */; }; @@ -197,6 +197,7 @@ 4C4733D72F1FA388005D2376 /* AcrylicPhotoVM+Crop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4733522F1FA388005D2376 /* AcrylicPhotoVM+Crop.swift */; }; 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 */; }; 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 */; }; @@ -549,7 +550,7 @@ 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 /* WorkshopMakingKeyringSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopMakingKeyringSection.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 = ""; }; @@ -626,6 +627,7 @@ 4C47338B2F1FA388005D2376 /* SpeechBubbleFramePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechBubbleFramePreviewView.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1093,13 +1095,14 @@ 4C47331F2F1FA2AB005D2376 /* Main */ = { isa = PBXGroup; children = ( - 4C4733182F1FA2AB005D2376 /* WorkshopGridHelpers.swift */, + 4C47331E2F1FA2AB005D2376 /* WorkshopView.swift */, + 4C47331D2F1FA2AB005D2376 /* WorkshopTopBannerSection.swift */, 4C4733192F1FA2AB005D2376 /* WorkshopMainContentSection.swift */, - 4C47331A2F1FA2AB005D2376 /* WorkshopMakingKeyringSection.swift */, 4C47331B2F1FA2AB005D2376 /* WorkshopStickyHeaderSection.swift */, 4C47331C2F1FA2AB005D2376 /* WorkshopTemplatesView.swift */, - 4C47331D2F1FA2AB005D2376 /* WorkshopTopBannerSection.swift */, - 4C47331E2F1FA2AB005D2376 /* WorkshopView.swift */, + 4C4733182F1FA2AB005D2376 /* WorkshopGridHelpers.swift */, + 4C4733202F1FA2AB005D2376 /* MyItemsView.swift */, + 4C4733212F1FA2AB005D2376 /* WorkshopPreview.swift */, ); path = Main; sourceTree = ""; @@ -1107,9 +1110,10 @@ 4C4733222F1FA2AB005D2376 /* Views */ = { isa = PBXGroup; children = ( + 4C4733152F1FA2AB005D2376 /* Components */, + 4C4733E02F20FD18005D2376 /* Keyring */, + 4C4733E22F20FD50005D2376 /* Bundle */, 4C47331F2F1FA2AB005D2376 /* Main */, - 4C4733202F1FA2AB005D2376 /* MyItemsView.swift */, - 4C4733212F1FA2AB005D2376 /* WorkshopPreview.swift */, ); path = Views; sourceTree = ""; @@ -1399,6 +1403,22 @@ path = KeyringMaker; sourceTree = ""; }; + 4C4733E02F20FD18005D2376 /* Keyring */ = { + isa = PBXGroup; + children = ( + 4C4733E42F20FE34005D2376 /* WorkshopRecentTemplate.swift */, + ); + path = Keyring; + sourceTree = ""; + }; + 4C4733E22F20FD50005D2376 /* Bundle */ = { + isa = PBXGroup; + children = ( + 4C47331A2F1FA2AB005D2376 /* WorkshopBundleBanner.swift */, + ); + path = Bundle; + sourceTree = ""; + }; 4C65303C2EBA5FF3000F8154 /* Alerts */ = { isa = PBXGroup; children = ( @@ -1963,7 +1983,6 @@ 4CEC62682EAE08DF0099ECEE /* Workshop */ = { isa = PBXGroup; children = ( - 4C4733152F1FA2AB005D2376 /* Components */, 4C4733172F1FA2AB005D2376 /* ViewModels */, 4C4733222F1FA2AB005D2376 /* Views */, C665DDE92EAEFAA800CE4495 /* Coin */, @@ -2394,6 +2413,7 @@ 4CEC621E2EAE08DA0099ECEE /* KeyringChainComponent.swift in Sources */, 4CEC621F2EAE08DA0099ECEE /* KeyringBodyComponent.swift in Sources */, 4CEC62202EAE08DA0099ECEE /* View+Extension.swift in Sources */, + 4C4733E52F20FE34005D2376 /* WorkshopRecentTemplate.swift in Sources */, 38173D0C2EB8AD8800E36F7E /* CategoryContextMenu.swift in Sources */, C6830F172EBB08380059379A /* MultiKeyringScene.swift in Sources */, C6830F182EBB08380059379A /* MultiKeyringSceneView.swift in Sources */, @@ -2560,7 +2580,7 @@ 4C4733242F1FA2AB005D2376 /* WorkshopPreview.swift in Sources */, 4C4733252F1FA2AB005D2376 /* MyItemsView.swift in Sources */, 4C4733262F1FA2AB005D2376 /* WorkshopGridHelpers.swift in Sources */, - 4C4733272F1FA2AB005D2376 /* WorkshopMakingKeyringSection.swift in Sources */, + 4C4733272F1FA2AB005D2376 /* WorkshopBundleBanner.swift in Sources */, 4C4733282F1FA2AB005D2376 /* WorkshopTemplatesView.swift in Sources */, 4C4733292F1FA2AB005D2376 /* WorkshopMainContentSection.swift in Sources */, 4C47332A2F1FA2AB005D2376 /* WorkshopStickyHeaderSection.swift in Sources */, diff --git a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift index c2dd4dec0..c8603b576 100644 --- a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift +++ b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift @@ -173,6 +173,15 @@ class WorkshopViewModel { } } + /// 최근 사용 템플릿 (최신순, 최대 5개) + var recentTemplates: [KeyringTemplate] { + guard let user = userManager.currentUser else { return [] } + // recentTemplates ID 순서대로 템플릿 객체 반환 + return user.recentTemplates.compactMap { templateId in + templates.first { $0.id == templateId } + } + } + private var userManager: UserManager init(userManager: UserManager) { From b302b827a0952739419f361ad0aa1b07240ffd9d Mon Sep 17 00:00:00 2001 From: giljihun Date: Wed, 21 Jan 2026 23:54:14 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20WorkshopView=20-=20=EC=B5=9C?= =?UTF-8?q?=EA=B7=BC=20=EC=82=AC=EC=9A=A9=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그 외, 하이파이 재조정 --- .../Keyring/WorkshopRecentTemplate.swift | 136 +++++++++++++++++- .../Workshop/Views/Main/WorkshopView.swift | 28 +++- 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift index 40e022112..d8c10a4d7 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift @@ -6,13 +6,143 @@ // import SwiftUI +import NukeUI + +// MARK: - 최근 사용 템플릿 섹션 struct WorkshopRecentTemplate: View { + let templates: [KeyringTemplate] + let isLoading: Bool + var onTemplateTap: ((KeyringTemplate) -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // 섹션 타이틀 + Text("최근 사용 템플릿") + .typography(.suit17B) + .foregroundColor(.black100) + .padding(.horizontal, 20) + + // 콘텐츠 + if isLoading { + loadingView + } else if templates.isEmpty { + recentEmptyView + } else { + templateScrollView + } + } + } + + // MARK: - 로딩 뷰 + + private var loadingView: some View { + HStack(spacing: 12) { + ForEach(0..<3, id: \.self) { _ in + SkeletonBox(width: 112, height: 112) + } + } + .padding(.horizontal, 20) + } + + // MARK: - 빈 상태 뷰 + private var recentEmptyView: some View { + HStack { + Spacer() + Text("키링을 만들면 최근 사용한 템플릿이 이곳에 표시됩니다") + .typography(.suit14R18) + .foregroundColor(.gray500) + .multilineTextAlignment(.center) + Spacer() + } + .frame(height: 100) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.white70) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray50, lineWidth: 1) + ) + .padding(.horizontal, 20) + } + + // MARK: - 템플릿 스크롤 뷰 + + private var templateScrollView: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 7) { + ForEach(templates, id: \.id) { template in + RecentTemplateCard(template: template) { + onTemplateTap?(template) + } + } + } + .padding(.horizontal, 20) + } + } +} + +// MARK: - 개별 템플릿 카드 +private struct RecentTemplateCard: View { + let template: KeyringTemplate + let onTap: () -> Void + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Button(action: onTap) { + ZStack { + LazyImage(url: URL(string: template.thumbnailURL)) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + } else if state.isLoading { + LoadingAlert(type: .short30, message: nil) + } else { + Color.gray50 + .frame(width: 112, height: 112) + } + } + .padding(5) + + // 유료 아이콘 + if !template.isFree { + VStack { + HStack { + Image(.myCoinMini) + + Spacer() + } + .padding(.top, 7) + .padding(.leading, 7) + Spacer() + } + } + } + .frame(width: 112, height: 112) + .background(Color.white) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.gray50, lineWidth: 1) + ) + } + .buttonStyle(.plain) } } -#Preview { - WorkshopRecentTemplate() +// MARK: - Preview + +#Preview("로딩 상태") { + WorkshopRecentTemplate( + templates: [], + isLoading: true + ) +} + +#Preview("빈 상태") { + WorkshopRecentTemplate( + templates: [], + isLoading: false + ) } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift index 09e351a2d..7397d42d9 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift @@ -116,14 +116,15 @@ struct WorkshopView: View { VStack(spacing: 0) { // 상단 배너 (코인 버튼 + 타이틀) topBannerSection - + Spacer() - .frame(height: 64) - - makingKeyringSection - + .frame(height: 106) + + // 최근 사용 템플릿 + recentTemplateSection + Spacer() - .frame(height: 14) + .frame(height: 43) // 메인 콘텐츠 (그리드) mainContentSection @@ -165,6 +166,21 @@ extension WorkshopView { } } +// 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 {