Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Keychy/Keychy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -681,6 +683,8 @@
4C77753C2EB1343600981C3E /* IntroViewModel+Signup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntroViewModel+Signup.swift"; sourceTree = "<group>"; };
4C84265F2ED3585A0050B6FE /* gulimche-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = text; path = "gulimche-Regular.ttf"; sourceTree = "<group>"; };
4C8426632ED375840050B6FE /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = "<group>"; };
4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopBundleGridView.swift; sourceTree = "<group>"; };
4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopKeyringGridView.swift; sourceTree = "<group>"; };
4CA9C6A42EC9D11600CA546B /* CustomNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNavigationBar.swift; sourceTree = "<group>"; };
4CA9C6A72EC9DB5300CA546B /* View+SafeAreaBottom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+SafeAreaBottom.swift"; sourceTree = "<group>"; };
4CA9C6D52ECB7AEA00CA546B /* BadWords.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = BadWords.json; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1434,6 +1438,7 @@
isa = PBXGroup;
children = (
4C4733E42F20FE34005D2376 /* WorkshopRecentTemplate.swift */,
4C86A6132F25C0BA0023AA2D /* WorkshopKeyringGridView.swift */,
);
path = Keyring;
sourceTree = "<group>";
Expand All @@ -1442,6 +1447,7 @@
isa = PBXGroup;
children = (
4C47331A2F1FA2AB005D2376 /* WorkshopBundleBanner.swift */,
4C86A6112F25C0B10023AA2D /* WorkshopBundleGridView.swift */,
);
path = Bundle;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
29 changes: 6 additions & 23 deletions Keychy/Keychy/Core/Components/CategoryTabBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/// 공방에서 판매되는 모든 아이템이 준수해야 하는 프로토콜
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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("템플릿")
Expand All @@ -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)
}
}

Expand All @@ -263,8 +319,8 @@ class WorkshopViewModel {
// WorkshopDataManager를 통해 캐싱된 데이터 가져오기
await dataManager.fetchAllDataIfNeeded()

// 모든 카테고리를 로드된 것으로 표시
loadedCategories = ["템플릿", "배경", "카라비너", "이펙트"]
// 모든 데이터 타입을 로드된 것으로 표시
loadedCategories = ["템플릿", "배경", "카라비너"]

// 데이터를 가져온 후 사용 가능한 태그 추출
extractAvailableTags()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WorkshopRoute>

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)
}
}
Loading