diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 1b6744b6..68036c54 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -243,6 +243,8 @@ 4C8426602ED3585A0050B6FE /* gulimche-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4C84265F2ED3585A0050B6FE /* gulimche-Regular.ttf */; }; 4C8426642ED375840050B6FE /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8426632ED375840050B6FE /* ColorPalette.swift */; }; 4C84A1602EB134BD008FFE57 /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A596832EAFEAA20003D712 /* ProfileSetupView.swift */; }; + 4C86A6122F25C0B10023AA2D /* WorkshopBundleGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */; }; + 4C86A6142F25C0BA0023AA2D /* WorkshopKeyringGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */; }; 4CA9C6A62EC9D11600CA546B /* CustomNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9C6A42EC9D11600CA546B /* CustomNavigationBar.swift */; }; 4CA9C6A82EC9DB5300CA546B /* View+SafeAreaBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9C6A72EC9DB5300CA546B /* View+SafeAreaBottom.swift */; }; 4CA9C6D62ECB7AEA00CA546B /* BadWords.json in Resources */ = {isa = PBXBuildFile; fileRef = 4CA9C6D52ECB7AEA00CA546B /* BadWords.json */; }; @@ -681,6 +683,8 @@ 4C77753C2EB1343600981C3E /* IntroViewModel+Signup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntroViewModel+Signup.swift"; sourceTree = ""; }; 4C84265F2ED3585A0050B6FE /* gulimche-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = text; path = "gulimche-Regular.ttf"; sourceTree = ""; }; 4C8426632ED375840050B6FE /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = ""; }; + 4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopBundleGridView.swift; sourceTree = ""; }; + 4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopKeyringGridView.swift; sourceTree = ""; }; 4CA9C6A42EC9D11600CA546B /* CustomNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNavigationBar.swift; sourceTree = ""; }; 4CA9C6A72EC9DB5300CA546B /* View+SafeAreaBottom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+SafeAreaBottom.swift"; sourceTree = ""; }; 4CA9C6D52ECB7AEA00CA546B /* BadWords.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = BadWords.json; sourceTree = ""; }; @@ -1434,6 +1438,7 @@ isa = PBXGroup; children = ( 4C4733E42F20FE34005D2376 /* WorkshopRecentTemplate.swift */, + 4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */, ); path = Keyring; sourceTree = ""; @@ -1442,6 +1447,7 @@ isa = PBXGroup; children = ( 4C47331A2F1FA2AB005D2376 /* WorkshopBundleBanner.swift */, + 4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */, ); path = Bundle; sourceTree = ""; @@ -2617,6 +2623,7 @@ 4C4733272F1FA2AB005D2376 /* WorkshopBundleBanner.swift in Sources */, 4C4733282F1FA2AB005D2376 /* WorkshopTemplatesView.swift in Sources */, 4C47332D2F1FA2AB005D2376 /* WorkshopViewModel.swift in Sources */, + 4C86A6142F25C0BA0023AA2D /* WorkshopKeyringGridView.swift in Sources */, 386B17642ECD142600CCCC23 /* String+Extension.swift in Sources */, 4CEBB1652EFBA54200CF53E2 /* RootViewModel.swift in Sources */, 4CC3D37F2EC464D70009D376 /* IntroAppGuidingView.swift in Sources */, @@ -2650,6 +2657,7 @@ 38C147C72EB1F57F00A8E511 /* StorageManager.swift in Sources */, 4CEC629F2EAE09990099ECEE /* BundleDetailView.swift in Sources */, 4CEBB1692EFBDCE900CF53E2 /* NetworkManager.swift in Sources */, + 4C86A6122F25C0B10023AA2D /* WorkshopBundleGridView.swift in Sources */, 4CEC62A02EAE09990099ECEE /* BundleInventoryView.swift in Sources */, 38283A812EBF511C00BE45A5 /* PackagePopup.swift in Sources */, 4CC8D00B2EEFC36900317467 /* AlarmViewModel.swift in Sources */, diff --git a/Keychy/Keychy/Core/Components/CategoryTabBar.swift b/Keychy/Keychy/Core/Components/CategoryTabBar.swift index 1d1b278a..ce3e886a 100644 --- a/Keychy/Keychy/Core/Components/CategoryTabBar.swift +++ b/Keychy/Keychy/Core/Components/CategoryTabBar.swift @@ -60,15 +60,15 @@ private struct CategoryTabButton: View { Button(action: action) { VStack(spacing: Spacing.sm) { Text(title) - .typography(isSelected ? .suit15B25 : .suit15SB25) - .foregroundStyle(isSelected ? Color.main500 : Color.black100) - + .typography(.suit15SB25) + .foregroundStyle(isSelected ? .main500 : .black100) + Rectangle() - .fill(isSelected ? Color.main500 : Color.clear) + .fill(isSelected ? .main500 : .clear) .frame(height: 2) - .padding(.horizontal, -4) // 좌우로 2pt씩 확장 + .padding(.horizontal, 10) } - .fixedSize(horizontal: true, vertical: false) + .contentShape(Rectangle()) } .frame(maxWidth: .infinity) .buttonStyle(.plain) @@ -77,20 +77,3 @@ private struct CategoryTabButton: View { } } } - -// MARK: - Preview - -#Preview("예시 프리뷰") { - @Previewable @State var selectedCategory = "키링" - let categories = ["키링", "카라비너", "이펫트", "배경"] - - VStack() { - CategoryTabBar( - categories: categories, - selectedCategory: $selectedCategory - ) - - Spacer() - } - .padding() -} diff --git a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift index e3f3c552..6512ded4 100644 --- a/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift +++ b/Keychy/Keychy/Presentation/Workshop/ViewModels/WorkshopViewModel.swift @@ -20,6 +20,27 @@ enum EffectFilterType: String, CaseIterable { case sound = "사운드" case particle = "파티클" } + +/// 퀵 필터 타입 (무료, 마이) +enum QuickFilter: CaseIterable { + case free + case owned + + var title: String { + switch self { + case .free: return "무료" + case .owned: return "마이" + } + } + + var icon: ImageResource? { + switch self { + case .free: return nil + case .owned: return .quickFilterOwned + } + } + +} // MARK: - WorkshopItem Protocol /// 공방에서 판매되는 모든 아이템이 준수해야 하는 프로토콜 @@ -82,13 +103,39 @@ extension Sound: WorkshopItem { @MainActor @Observable class WorkshopViewModel { + // MARK: - Tab & Category Properties + + /// 키링/뭉치 탭 토글 (true = 키링, false = 뭉치) + var workshopToggle: Bool = true { + didSet { + // 탭 전환 시 카테고리 초기화 + selectedCategory = currentCategories.first ?? "" + resetFilters() + } + } + + /// 키링 탭 카테고리 + let keyringCategories = ["전체", "이미지", "텍스트", "드로잉"] + + /// 뭉치 탭 카테고리 + let bundleCategories = ["카라비너", "배경"] + + /// 현재 탭에 따른 카테고리 목록 + var currentCategories: [String] { + workshopToggle ? keyringCategories : bundleCategories + } + // MARK: - Published Properties - var selectedCategory: String = "템플릿" + var selectedCategory: String = "전체" var selectedTemplateFilter: TemplateFilterType? = nil var selectedCommonFilter: String? = nil var selectedEffectFilter: EffectFilterType? = .sound var sortOrder: String = "최신순" var showFilterSheet: Bool = false + + // MARK: - Quick Filter Properties + var showFreeOnly: Bool = false + var showOwnedOnly: Bool = false var mainContentOffset: CGFloat = 439 private var refreshTrigger: Bool = false @@ -212,15 +259,16 @@ class WorkshopViewModel { // MARK: - Firebase Methods (통합) /// 특정 카테고리의 데이터만 가져오기 func fetchDataForCategory(_ category: String) async { - // 이미 로드된 카테고리는 스킵 - guard !loadedCategories.contains(category) else { return } + // 데이터 타입별로 로드 여부 확인 + let dataType = categoryToDataType(category) + guard !loadedCategories.contains(dataType) else { return } isLoading = true errorMessage = nil defer { isLoading = false } - switch category { + switch dataType { case "템플릿": await dataManager.fetchTemplatesIfNeeded() loadedCategories.insert("템플릿") @@ -232,24 +280,32 @@ class WorkshopViewModel { await dataManager.fetchCarabinersIfNeeded() extractAvailableTags() loadedCategories.insert("카라비너") - case "이펙트": - await dataManager.fetchParticlesIfNeeded() - await dataManager.fetchSoundsIfNeeded() - loadedCategories.insert("이펙트") default: break } } - /// 나머지 카테고리들을 백그라운드에서 프리페칭 - func prefetchRemainingData() async { - let allCategories = ["템플릿", "배경", "카라비너", "이펙트"] + /// 카테고리 → 데이터 타입 매핑 + private func categoryToDataType(_ category: String) -> String { + switch category { + // 키링 탭 카테고리 → 템플릿 + case "전체", "이미지", "텍스트", "드로잉": + return "템플릿" + // 뭉치 탭 카테고리 → 그대로 + case "카라비너", "배경": + return category + default: + return category + } + } - for category in allCategories { - // 이미 로드된 카테고리는 스킵 - guard !loadedCategories.contains(category) else { continue } + /// 나머지 데이터를 백그라운드에서 프리페칭 + func prefetchRemainingData() async { + let allDataTypes = ["템플릿", "배경", "카라비너"] - await fetchDataForCategory(category) + for dataType in allDataTypes { + guard !loadedCategories.contains(dataType) else { continue } + await fetchDataForCategory(dataType) } } @@ -263,8 +319,8 @@ class WorkshopViewModel { // WorkshopDataManager를 통해 캐싱된 데이터 가져오기 await dataManager.fetchAllDataIfNeeded() - // 모든 카테고리를 로드된 것으로 표시 - loadedCategories = ["템플릿", "배경", "카라비너", "이펙트"] + // 모든 데이터 타입을 로드된 것으로 표시 + loadedCategories = ["템플릿", "배경", "카라비너"] // 데이터를 가져온 후 사용 가능한 태그 추출 extractAvailableTags() diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleGridView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleGridView.swift new file mode 100644 index 00000000..a77af3da --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Bundle/WorkshopBundleGridView.swift @@ -0,0 +1,130 @@ +// +// WorkshopBundleGridView.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI + +/// 뭉치 탭 그리드 뷰 (카라비너, 배경 표시) +/// 카테고리: 카라비너, 배경 +struct WorkshopBundleGridView: View { + @Bindable var viewModel: WorkshopViewModel + @Bindable var router: NavigationRouter + + var body: some View { + VStack { + if viewModel.isLoading { + loadingView + } else { + if let errorMessage = viewModel.errorMessage { + errorView(message: errorMessage) + } else { + bundleGridContent + } + } + } + .background(.white100) + } + + /// 필터링된 카라비너 목록 + private var filteredCarabiners: [Carabiner] { + var result = viewModel.filteredCarabiners + + if viewModel.showFreeOnly { + result = result.filter { $0.isFree } + } + if viewModel.showOwnedOnly { + result = result.filter { viewModel.isCarabinerOwned($0) } + } + + return result + } + + /// 필터링된 배경 목록 + private var filteredBackgrounds: [Background] { + var result = viewModel.filteredBackgrounds + + if viewModel.showFreeOnly { + result = result.filter { $0.isFree } + } + if viewModel.showOwnedOnly { + result = result.filter { viewModel.isBackgroundOwned($0) } + } + + return result + } + + /// 뭉치 그리드 콘텐츠 (카테고리별) + @ViewBuilder + private var bundleGridContent: some View { + switch viewModel.selectedCategory { + case "카라비너": + WorkshopGridBuilder.itemGridView( + items: filteredCarabiners, + isOwnedCheck: viewModel.isCarabinerOwned, + router: router, + viewModel: viewModel, + emptyView: emptyContentView + ) + case "배경": + WorkshopGridBuilder.itemGridView( + items: filteredBackgrounds, + isOwnedCheck: viewModel.isBackgroundOwned, + router: router, + viewModel: viewModel, + emptyView: emptyContentView + ) + default: + emptyContentView + } + } + + /// 로딩 뷰 (스켈레톤) + private var loadingView: some View { + HStack(spacing: 11) { + WorkshopSkeletonBox(width: twoGridCellWidth, height: twoSquareGridCellSize) + WorkshopSkeletonBox(width: twoGridCellWidth, height: twoSquareGridCellSize) + } + .padding(.horizontal, 16) + .padding(.vertical, 92) + } + + /// 빈 콘텐츠 뷰 + private var emptyContentView: some View { + VStack(spacing: 12) { + Image(.emptyViewIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 90) + + Text("준비중이에요") + .typography(.suit14SB18) + .foregroundColor(.gray500) + } + .frame(maxWidth: .infinity, minHeight: 300) + .padding(.top, 50) + } + + /// 에러 뷰 + private func errorView(message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundStyle(.secondary) + + Text(message) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("다시 시도") { + Task { + await viewModel.fetchAllData() + } + } + .buttonStyle(.borderedProminent) + } + .padding(.top, 100) + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterBar.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterBar.swift index 197c39f7..a10affed 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterBar.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopFilterBar.swift @@ -14,18 +14,15 @@ struct WorkshopFilterBar: View { @Binding var viewModel: WorkshopViewModel var body: some View { - HStack(spacing: 8) { + HStack(spacing: 0) { // 정렬 버튼 (고정) sortButton - // 카테고리별 필터 (스크롤 가능) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - categorySpecificFilters - } - } + Spacer() + + // 퀵 필터 버튼 (무료, 마이) + quickFilters } - .padding(.top, 12) } /// 정렬 버튼 @@ -52,57 +49,104 @@ struct WorkshopFilterBar: View { .buttonStyle(PlainButtonStyle()) } - /// 카테고리별 필터 옵션 - private var categorySpecificFilters: some View { - Group { - switch viewModel.selectedCategory { - case "템플릿": - ForEach(TemplateFilterType.allCases, id: \.self) { filter in - WorkshopFilterChip( - title: filter.rawValue, - isSelected: viewModel.selectedTemplateFilter == filter - ) { - viewModel.selectedTemplateFilter = - viewModel.selectedTemplateFilter == filter ? nil : filter - } - } + // MARK: - 퀵 필터 + /// 퀵 필터 버튼 그룹 + private var quickFilters: some View { + HStack(spacing: 15) { + ForEach(QuickFilter.allCases, id: \.self) { filter in + quickFilterButton(filter) + } + } + } - case "이펙트": - ForEach(EffectFilterType.allCases, id: \.self) { filter in - WorkshopFilterChip( - title: filter.rawValue, - isSelected: viewModel.selectedEffectFilter == filter - ) { - viewModel.selectedEffectFilter = - viewModel.selectedEffectFilter == filter ? nil : filter - } - } + /// 퀵 필터 선택 여부 + private func isSelected(_ filter: QuickFilter) -> Bool { + switch filter { + case .free: return viewModel.showFreeOnly + case .owned: return viewModel.showOwnedOnly + } + } + + /// 퀵 필터 토글 (라디오 버튼 스타일 - 하나만 선택 가능) + private func toggle(_ filter: QuickFilter) { + switch filter { + case .free: + viewModel.showFreeOnly.toggle() + if viewModel.showFreeOnly { + viewModel.showOwnedOnly = false + } + case .owned: + viewModel.showOwnedOnly.toggle() + if viewModel.showOwnedOnly { + viewModel.showFreeOnly = false + } + } + } - case "카라비너": - ForEach(viewModel.availableCarabinerTags, id: \.self) { tag in - WorkshopFilterChip( - title: tag, - isSelected: viewModel.selectedCommonFilter == tag - ) { - viewModel.selectedCommonFilter = - viewModel.selectedCommonFilter == tag ? nil : tag - } + /// 퀵 필터 버튼 + private func quickFilterButton(_ filter: QuickFilter) -> some View { + Button { + toggle(filter) + } label: { + HStack(spacing: 4) { + if let icon = filter.icon { + Image(icon) + } + + HStack(spacing: 5) { + Text(filter.title) + .typography(.suit14SB) + .foregroundColor(.gray500) + + Circle() + .fill(isSelected(filter) ? Color.main500 : Color.clear) + .stroke(.gray100, lineWidth: 1) + .frame(width: 16, height: 16) + .overlay { + if isSelected(filter) { + Image(.quickFilterChecked) + } + } } + } + } + .buttonStyle(.plain) + } + + // MARK: - 카테고리별 필터 옵션, 현재 미사용 + @ViewBuilder + private var categorySpecificFilters: some View { + switch viewModel.selectedCategory { + // 키링 탭: 전체/이미지/텍스트/드로잉은 카테고리 자체가 필터 → 추가 필터 없음 + case "전체", "이미지", "텍스트", "드로잉": + EmptyView() - case "배경": - ForEach(viewModel.availableBackgroundTags, id: \.self) { tag in - WorkshopFilterChip( - title: tag, - isSelected: viewModel.selectedCommonFilter == tag - ) { - viewModel.selectedCommonFilter = - viewModel.selectedCommonFilter == tag ? nil : tag - } + // 뭉치 탭: 카라비너 태그 필터 + case "카라비너": + ForEach(viewModel.availableCarabinerTags, id: \.self) { tag in + WorkshopFilterChip( + title: tag, + isSelected: viewModel.selectedCommonFilter == tag + ) { + viewModel.selectedCommonFilter = + viewModel.selectedCommonFilter == tag ? nil : tag } + } - default: - EmptyView() + // 뭉치 탭: 배경 태그 필터 + case "배경": + ForEach(viewModel.availableBackgroundTags, id: \.self) { tag in + WorkshopFilterChip( + title: tag, + isSelected: viewModel.selectedCommonFilter == tag + ) { + viewModel.selectedCommonFilter = + viewModel.selectedCommonFilter == tag ? nil : tag + } } + + default: + EmptyView() } } } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift index b6f8b7c9..e146d990 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopGridBuilder.swift @@ -37,7 +37,8 @@ struct WorkshopGridBuilder { } } .padding(.horizontal, 16) - .padding(.vertical, 92) + .padding(.top, 80) + .padding(.bottom, 92) } } } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift index d9f52956..696fe5a7 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopItemCard.swift @@ -155,11 +155,11 @@ struct WorkshopPriceOverlay: View { // 유료 아이콘 VStack { HStack { - Image(.paidIcon) + Image(.myCoin) Spacer() } - .padding(.top, 7) - .padding(.leading, 10) + .padding(.top, 9.84) + .padding(.leading, 9.35) Spacer() } .opacity(isFree ? 0 : 1) diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopMakeMenu.swift b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopMakeMenu.swift index a2db4c41..ddbe71ea 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopMakeMenu.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Components/WorkshopMakeMenu.swift @@ -61,7 +61,7 @@ struct WorkshopMakeMenu: View { .opacity(isAppearing ? 1.0 : 0.0) .position( x: geometry.size.width - menuWidth / 2 - 16, - y: position.maxY - geometry.safeAreaInsets.top + 5 + menuHeight / 2 + y: geometry.safeAreaInsets.top + menuHeight / 2 - 5 // (맨 뒤에 상수값을 조정해서 위치 조정 가능) ) } } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopKeyringGridView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopKeyringGridView.swift new file mode 100644 index 00000000..2aaa10fc --- /dev/null +++ b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopKeyringGridView.swift @@ -0,0 +1,118 @@ +// +// WorkshopKeyringGridView.swift +// Keychy +// +// Created by 길지훈 on 1/22/26. +// + +import SwiftUI + +/// 키링 탭 그리드 뷰 (템플릿 표시) +/// 카테고리: 전체, 이미지, 텍스트, 드로잉 +struct WorkshopKeyringGridView: View { + @Bindable var viewModel: WorkshopViewModel + @Bindable var router: NavigationRouter + + var body: some View { + VStack { + if viewModel.isLoading { + loadingView + } else { + if let errorMessage = viewModel.errorMessage { + errorView(message: errorMessage) + } else { + templateGridContent + } + } + } + .background(.white100) + } + + /// 필터링된 템플릿 목록 + private var filteredTemplates: [KeyringTemplate] { + var result: [KeyringTemplate] + + // 1. 카테고리 필터 + switch viewModel.selectedCategory { + case "전체": + result = viewModel.filteredTemplates + case "이미지": + result = viewModel.templates.filter { $0.tags.contains("이미지") } + case "텍스트": + result = viewModel.templates.filter { $0.tags.contains("텍스트") } + case "드로잉": + result = viewModel.templates.filter { $0.tags.contains("드로잉") } + default: + result = viewModel.filteredTemplates + } + + // 2. 퀵 필터 적용 + if viewModel.showFreeOnly { + result = result.filter { $0.isFree } + } + if viewModel.showOwnedOnly { + result = result.filter { viewModel.isTemplateOwned($0) } + } + + return result + } + + /// 템플릿 그리드 콘텐츠 + private var templateGridContent: some View { + WorkshopGridBuilder.itemGridView( + items: filteredTemplates, + isOwnedCheck: viewModel.isTemplateOwned, + router: router, + viewModel: viewModel, + emptyView: emptyContentView + ) + } + + /// 로딩 뷰 (스켈레톤) + private var loadingView: some View { + HStack(spacing: 11) { + WorkshopSkeletonBox(width: twoGridCellWidth, height: twoGridCellHeight) + WorkshopSkeletonBox(width: twoGridCellWidth, height: twoGridCellHeight) + } + .padding(.horizontal, 16) + .padding(.top, 80) + .padding(.bottom, 92) + } + + /// 빈 콘텐츠 뷰 + private var emptyContentView: some View { + VStack(spacing: 12) { + Image(.emptyViewIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 90) + + Text("준비중이에요") + .typography(.suit14SB18) + .foregroundColor(.gray500) + } + .frame(maxWidth: .infinity, minHeight: 300) + .padding(.top, 50) + } + + /// 에러 뷰 + private func errorView(message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundStyle(.secondary) + + Text(message) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button("다시 시도") { + Task { + await viewModel.fetchAllData() + } + } + .buttonStyle(.borderedProminent) + } + .padding(.top, 100) + } +} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift index 0a635354..cf81bef7 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Keyring/WorkshopRecentTemplate.swift @@ -130,19 +130,3 @@ private struct RecentTemplateCard: View { .buttonStyle(.plain) } } - -// MARK: - Preview - -#Preview("로딩 상태") { - WorkshopRecentTemplate( - templates: [], - isLoading: true - ) -} - -#Preview("빈 상태") { - WorkshopRecentTemplate( - templates: [], - isLoading: false - ) -} diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift index af2f5461..8a4d5a29 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+MainContent.swift @@ -10,136 +10,19 @@ import SwiftUI // MARK: - Main Content Section extension WorkshopView { - /// 메인 콘텐츠 영역 (카테고리별 그리드) + /// 메인 콘텐츠 영역 (키링/뭉치 탭에 따른 그리드) var mainContentSection: some View { - VStack { - if viewModel.isLoading { - loadingView - } else { - if let errorMessage = viewModel.errorMessage { - errorView(message: errorMessage) - } else { - categoryContent - } - } - } - .background(.white100) - } - - /// 로딩 뷰 (스켈레톤 애니메이션) - var loadingView: some View { - HStack(spacing: 11){ - WorkshopSkeletonBox(width: twoGridCellWidth, height: twoGridCellHeight) - WorkshopSkeletonBox(width: twoGridCellWidth, height: twoGridCellHeight) - } - .padding(.horizontal, 16) - .padding(.vertical, 92) - } - - - /// 카테고리별 콘텐츠 - var categoryContent: some View { Group { - switch viewModel.selectedCategory { - - //case "KEYCHY!": keychyContentView - - case "템플릿": - itemGridView(items: viewModel.filteredTemplates, - isOwnedCheck: viewModel.isTemplateOwned) - case "배경": - itemGridView(items: viewModel.filteredBackgrounds, - isOwnedCheck: viewModel.isBackgroundOwned) - case "카라비너": - itemGridView(items: viewModel.filteredCarabiners, - isOwnedCheck: viewModel.isCarabinerOwned) - case "이펙트": - effectContentView - default: - emptyContentView + if viewModel.workshopToggle { + // 키링 탭: 템플릿 그리드 + WorkshopKeyringGridView(viewModel: viewModel, router: router) + } else { + // 뭉치 탭: 카라비너/배경 그리드 + WorkshopBundleGridView(viewModel: viewModel, router: router) } } } - /// 이펙트 전용 콘텐츠 (사운드 + 파티클) - var effectContentView: some View { - WorkshopGridBuilder.effectGridView( - items: viewModel.filteredEffects, - isSoundOwned: viewModel.isSoundOwned, - isParticleOwned: viewModel.isParticleOwned, - router: router, - viewModel: viewModel, - emptyView: emptyContentView - ) - } - - /// 통합 아이템 그리드 뷰 - func itemGridView( - items: [T], - isOwnedCheck: @escaping (T) -> Bool - ) -> some View { - WorkshopGridBuilder.itemGridView( - items: items, - isOwnedCheck: isOwnedCheck, - router: router, - viewModel: viewModel, - emptyView: emptyContentView - ) - } - - // MARK: - KEYCHY! 탭 (향후 추가 시 사용) - // /// KEYCHY! 전용 콘텐츠 (준비 중) - // var keychyContentView: some View { - // VStack(spacing: 12) { - // Image(systemName: "sparkles") - // .font(.system(size: 20, weight: .semibold)) - // .foregroundStyle(.purple).opacity(0.6) - // - // Text("KEYCHY! 디자이너 열일중..") - // .typography(.suit14SB18) - // .foregroundColor(.gray500) - // .multilineTextAlignment(.center) - // } - // .frame(maxWidth: .infinity, minHeight: 300) - // .padding(.top, 50) - // } - - /// 빈 콘텐츠 뷰 - var emptyContentView: some View { - VStack(spacing: 12) { - Image(.emptyViewIcon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 90) - - Text("준비중이에요") - .typography(.suit14SB18) - .foregroundColor(.gray500) - } - .frame(maxWidth: .infinity, minHeight: 300) - .padding(.top, 50) - } - - /// 에러 뷰 - func errorView(message: String) -> some View { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 50)) - .foregroundStyle(.secondary) - - Text(message) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Button("다시 시도") { - Task { - await viewModel.fetchAllData() - } - } - .buttonStyle(.borderedProminent) - } - .padding(.top, 100) - } } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+NetworkError.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+NetworkError.swift index 2ea28752..d6f3f376 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+NetworkError.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+NetworkError.swift @@ -24,7 +24,7 @@ extension WorkshopView { HStack { titleView Spacer() - myItemBtn + makeBtn } .padding(.top, 60) .padding(.horizontal, 20) diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+StickyHeader.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+StickyHeader.swift index 18ffbdd0..54184e85 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+StickyHeader.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+StickyHeader.swift @@ -13,27 +13,22 @@ extension WorkshopView { /// 스티키 헤더 (카테고리 + 필터) var stickyHeaderSection: some View { VStack(spacing: 0) { - // 카테고리 탭바 + // 카테고리 탭바 (키링/뭉치 탭에 따라 동적 변경) CategoryTabBar( - categories: categories, + categories: viewModel.currentCategories, selectedCategory: $viewModel.selectedCategory ) - .padding(.top, 12) - + .padding(.bottom, 12) + // 필터바 - filterBar + WorkshopFilterBar(viewModel: $viewModel) } .padding(.horizontal, 20) - .padding(.bottom, 20) + .padding(.vertical, 12) .background(.white) .clipShape(.rect(cornerRadii: .init(topLeading: 20, topTrailing: 20))) .offset(y: max(WorkshopLayout.stickyHeaderMinOffset, min(WorkshopLayout.stickyHeaderMaxOffset, viewModel.mainContentOffset - WorkshopLayout.stickyHeaderOffsetAdjust))) } - - /// 필터바 - var filterBar: some View { - WorkshopFilterBar(viewModel: $viewModel) - } } diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift index c69a9060..b6152782 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView+TopSection.swift @@ -26,7 +26,7 @@ extension WorkshopView { HStack { titleView Spacer() - myItemBtn + makeBtn } .padding(.top, WorkshopLayout.topPadding) .padding(.horizontal, 20) @@ -35,52 +35,27 @@ extension WorkshopView { .opacity(viewModel.mainContentOffset - WorkshopLayout.titleBarOpacityThreshold < WorkshopLayout.titleBarOpacityRange ? 1 : 0) } - /// 타이틀 뷰 + /// 타이틀 뷰 (키링/뭉치 탭 전환) var titleView: some View { HStack(spacing: 10) { Button { - // TODO: - 공방 탭 액션 - workshopToggle = true + viewModel.workshopToggle = true } label: { Text("키링") .typography(.nanum24EB) - .foregroundStyle(workshopToggle ? .black100 : .gray100) + .foregroundStyle(viewModel.workshopToggle ? .black100 : .gray200) } - + Button { - // TODO: - 뭉치 탭 액션 - workshopToggle = false + viewModel.workshopToggle = false } label: { Text("뭉치") .typography(.nanum24EB) - .foregroundStyle(workshopToggle ? .gray100 : .black100) + .foregroundStyle(viewModel.workshopToggle ? .gray200 : .black100) } } } - /// 내 아이템 버튼 - var myItemBtn: some View { - Button { - router.push(.myItems) - } label: { - HStack(spacing: 0) { - Image(.myItem) - .resizable() - .scaledToFit() - - Spacer() - - Text("내 아이템") - .typography(.suit17B) - .foregroundColor(.black) - } - } - .frame(minWidth: 80) - .frame(height: 44) - .fixedSize(horizontal: true, vertical: true) - .buttonStyle(.glass) - } - /// 만들기 메뉴 버튼 var makeBtn: some View { Button { diff --git a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift index e7106450..c12da529 100644 --- a/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift +++ b/Keychy/Keychy/Presentation/Workshop/Views/Main/WorkshopView.swift @@ -30,15 +30,11 @@ struct WorkshopView: View { @Environment(UserManager.self) var userManager @State var viewModel: WorkshopViewModel @State private var hasInitialized = false - @State private var isTabBarVisible = true - @State var workshopToggle: Bool = true // 만들기 메뉴 상태 @State var showMakeMenu: Bool = false @State var makeMenuPosition: CGRect = .zero - let categories = ["템플릿", "카라비너", "이펙트", "배경"] - /// WorkshopTab에서 생성된 viewModel을 받아서 사용 init( router: NavigationRouter, @@ -74,7 +70,6 @@ struct WorkshopView: View { } } .ignoresSafeArea() - .toolbar(isTabBarVisible ? .visible : .hidden, for: .tabBar) .sheet(isPresented: $viewModel.showFilterSheet) { sortSheet } @@ -137,8 +132,10 @@ struct WorkshopView: View { Spacer() .frame(height: WorkshopLayout.recentTemplateTopSpacing) - // 최근 사용 템플릿 - recentTemplateSection + // 키링 탭일 때만 최근 사용 템플릿 표시 + if viewModel.workshopToggle { + recentTemplateSection + } Spacer() .frame(height: WorkshopLayout.mainContentTopSpacing) diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterChecked.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterChecked.imageset/Contents.json new file mode 100644 index 00000000..a23cee3f --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterChecked.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "quickFilterChecked.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterChecked.imageset/quickFilterChecked.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterChecked.imageset/quickFilterChecked.pdf new file mode 100644 index 00000000..1c23f32f Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterChecked.imageset/quickFilterChecked.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/Contents.json new file mode 100644 index 00000000..5d2280c3 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "quickFilterOwned.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/quickFilterOwned.pdf b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/quickFilterOwned.pdf new file mode 100644 index 00000000..f85f7e9d Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/02. WorkShop/quickFilterOwned.imageset/quickFilterOwned.pdf differ