From be968336f0fe5d091740f073d87f3d4541bc6021 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 17:18:40 +0900 Subject: [PATCH 01/12] =?UTF-8?q?refactor:=20Festival=20=ED=83=AD=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EC=97=AC=203=ED=83=AD=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tab/ViewModels/MainTabViewModel.swift | 39 ----------------- .../Presentation/Tab/Views/MainTabView.swift | 36 +++------------- .../Presentation/Tab/Views/WorkshopTab.swift | 43 +++---------------- 3 files changed, 13 insertions(+), 105 deletions(-) diff --git a/Keychy/Keychy/Presentation/Tab/ViewModels/MainTabViewModel.swift b/Keychy/Keychy/Presentation/Tab/ViewModels/MainTabViewModel.swift index 4dd070ed..b8aaf9be 100644 --- a/Keychy/Keychy/Presentation/Tab/ViewModels/MainTabViewModel.swift +++ b/Keychy/Keychy/Presentation/Tab/ViewModels/MainTabViewModel.swift @@ -16,7 +16,6 @@ class MainTabViewModel { case home = 0 case workshop = 1 case collection = 2 - case festival = 3 } /// 화면 전환 시 딜레이 시간 @@ -36,7 +35,6 @@ class MainTabViewModel { var homeRouter = NavigationRouter() var collectionRouter = NavigationRouter() var workshopRouter = NavigationRouter() - var festivalRouter = NavigationRouter() // Sheets var showReceiveSheet = false @@ -50,7 +48,6 @@ class MainTabViewModel { // ViewModels let collectionViewModel = CollectionViewModel() - let festivalViewModel = Showcase25BoardViewModel() // Managers let userManager = UserManager.shared @@ -77,43 +74,7 @@ class MainTabViewModel { } } - // MARK: - Public Methods - /// Festival 탭에서 Workshop 탭으로 전환하고 특정 라우트로 이동 - /// - Parameter route: 이동할 WorkshopRoute - func handleSwitchToWorkshop(_ route: WorkshopRoute) { - festivalViewModel.isFromFestivalTab = true - - selectedTab = TabIndex.workshop.rawValue - Task { @MainActor in - try? await Task.sleep(for: .seconds(Delay.tabSwitchAnimation)) - workshopRouter.push(route) - } - } - - /// Festival 탭에서 KeyringMaker로 전환하고 특정 라우트로 이동 - /// - Parameter route: 이동할 WorkshopRoute (키링 제작 라우트) - func handleSwitchToKeyringMaker(_ route: WorkshopRoute) { - setupFestivalReturnCallback() - - selectedTab = TabIndex.workshop.rawValue - Task { @MainActor in - try? await Task.sleep(for: .seconds(Delay.tabSwitchAnimation)) - workshopRouter.push(route) - } - } - // MARK: - Private Methods - /// Festival → KeyringMaker 이동 시 완료 후 복귀 콜백 설정 - private func setupFestivalReturnCallback() { - festivalViewModel.isFromFestivalTab = true - festivalViewModel.onKeyringCompleteFromFestival = { [weak self] (router: NavigationRouter) in - guard let self = self else { return } - router.reset() - self.selectedTab = TabIndex.festival.rawValue - self.festivalViewModel.isFromFestivalTab = false - } - } - /// 대기 중인 딥링크가 있는지 확인하고 처리 private func checkPendingDeepLink() { if let (postOfficeId, type) = deepLinkManager.consumePendingDeepLink() { diff --git a/Keychy/Keychy/Presentation/Tab/Views/MainTabView.swift b/Keychy/Keychy/Presentation/Tab/Views/MainTabView.swift index cf7f89c9..a9d61c57 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/MainTabView.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/MainTabView.swift @@ -9,7 +9,7 @@ import SwiftUI /// 앱의 메인 탭 화면 /// -/// 4개의 메인 탭(홈, 공방, 보관함, 페스티벌)을 관리하고 +/// 3개의 메인 탭(홈, 공방, 보관함)을 관리하고 /// 딥링크 처리, 스플래시 화면, 배지 카운트 동기화 등을 담당 struct MainTabView: View { @State private var viewModel = MainTabViewModel() @@ -50,7 +50,6 @@ extension MainTabView { homeTab workshopTab collectionTab - festivalTab } .tint(.main500) .tabBarMinimizeBehavior(.onScrollDown) @@ -97,16 +96,12 @@ extension MainTabView { } private var workshopTab: some View { - WorkshopTab( - router: viewModel.workshopRouter, - festivalRouter: viewModel.festivalRouter, - festivalVM: viewModel.festivalViewModel - ) - .modifier(TabItemModifier( - image: .workshop, - title: "공방", - tag: MainTabViewModel.TabIndex.workshop.rawValue - )) + WorkshopTab(router: viewModel.workshopRouter) + .modifier(TabItemModifier( + image: .workshop, + title: "공방", + tag: MainTabViewModel.TabIndex.workshop.rawValue + )) } private var collectionTab: some View { @@ -121,23 +116,6 @@ extension MainTabView { )) } - private var festivalTab: some View { - FestivalTab( - router: viewModel.festivalRouter, - showcaseVM: viewModel.festivalViewModel, - onSwitchToKeyringMaker: { route in - viewModel.handleSwitchToKeyringMaker(route) - }, - onSwitchToWorkshop: { route in - viewModel.handleSwitchToWorkshop(route) - } - ) - .modifier(TabItemModifier( - image: .festival, - title: "페스티벌", - tag: MainTabViewModel.TabIndex.festival.rawValue - )) - } } // MARK: - Sheet Contents diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index 0a4b37f1..ca8a6f9a 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -9,8 +9,6 @@ import SwiftUI struct WorkshopTab: View { @Bindable var router: NavigationRouter - @Bindable var festivalRouter: NavigationRouter - @Bindable var festivalVM: Showcase25BoardViewModel @State private var acrylicPhotoVM: AcrylicPhotoVM? @State private var neonSignVM: NeonSignVM? @@ -67,17 +65,6 @@ struct WorkshopTab: View { case .coinCharge: CoinChargeView(router: router) - // MARK: - 쇼케이스용 페스티벌 임시 라우트 - case .showcase25BoardView: - Showcase25BoardView(festivalRouter: festivalRouter, viewModel: festivalVM) - - case .festivalKeyringDetailView(let keyring): - FestivalKeyringDetailView( - festivalRouter: festivalRouter, - viewModel: festivalVM, - keyring: keyring - ) - // MARK: - AcrylicPhoto case .acrylicPhotoPreview: AcrylicPhotoPreView(router: router, viewModel: getAcrylicPhotoVM()) @@ -101,10 +88,7 @@ struct WorkshopTab: View { KeyringCompleteView( router: router, viewModel: getAcrylicPhotoVM(), - navigationTitle: "키링이 완성되었어요!", - onCloseFromFestival: festivalVM.isFromFestivalTab ? { router in - festivalVM.onKeyringCompleteFromFestival?(router) - } : nil + navigationTitle: "키링이 완성되었어요!" ) // MARK: - NeonSign @@ -126,10 +110,7 @@ struct WorkshopTab: View { KeyringCompleteView( router: router, viewModel: getNeonSignVM(), - navigationTitle: "키링이 완성되었어요!", - onCloseFromFestival: festivalVM.isFromFestivalTab ? { router in - festivalVM.onKeyringCompleteFromFestival?(router) - } : nil + navigationTitle: "키링이 완성되었어요!" ) // MARK: - Polaroid @@ -151,10 +132,7 @@ struct WorkshopTab: View { KeyringCompleteView( router: router, viewModel: getPolaroidVM(), - navigationTitle: "키링이 완성되었어요!", - onCloseFromFestival: festivalVM.isFromFestivalTab ? { router in - festivalVM.onKeyringCompleteFromFestival?(router) - } : nil + navigationTitle: "키링이 완성되었어요!" ) // MARK: - ClearSketch @@ -180,10 +158,7 @@ struct WorkshopTab: View { KeyringCompleteView( router: router, viewModel: getClearSketchVM(), - navigationTitle: "키링이 완성되었어요!", - onCloseFromFestival: festivalVM.isFromFestivalTab ? { router in - festivalVM.onKeyringCompleteFromFestival?(router) - } : nil + navigationTitle: "키링이 완성되었어요!" ) // MARK: - Pixel @@ -207,10 +182,7 @@ struct WorkshopTab: View { KeyringCompleteView( router: router, viewModel: getPixelKeyringVM(), - navigationTitle: "키링이 완성되었어요!", - onCloseFromFestival: festivalVM.isFromFestivalTab ? { router in - festivalVM.onKeyringCompleteFromFestival?(router) - } : nil + navigationTitle: "키링이 완성되었어요!" ) // MARK: - SpeechBubble @@ -232,10 +204,7 @@ struct WorkshopTab: View { KeyringCompleteView( router: router, viewModel: getSpeechBubbleVM(), - navigationTitle: "키링이 완성되었어요!", - onCloseFromFestival: festivalVM.isFromFestivalTab ? { router in - festivalVM.onKeyringCompleteFromFestival?(router) - } : nil + navigationTitle: "키링이 완성되었어요!" ) // MARK: - 선물 포장 완료 From cd9aa8b5f49ae93f307b5a99ffa24bf33e4a7fa5 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 17:19:21 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20Festival=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20HomeRoute=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift | 9 ++++++++- Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift | 4 ---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift index 7a2c39fd..399675ab 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift @@ -7,7 +7,7 @@ /// 홈 탭 enum HomeRoute: Hashable, BundleRoute { - // 나중에 추가 + // Bundle case bundleInventoryView case bundleDetailView case bundleCreateView @@ -15,6 +15,8 @@ enum HomeRoute: Hashable, BundleRoute { case bundleNameInputView case bundleNameEditView case bundleEditView + + // Home case coinCharge case myPageView case changeName @@ -23,4 +25,9 @@ enum HomeRoute: Hashable, BundleRoute { case notificationGiftView(postOfficeId: String) case introView case termsAndPolicy + + // Festival + case festivalView + case showcase25BoardView + case festivalKeyringDetailView(Keyring) } diff --git a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift index 5995cd38..b0c7e8ac 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift @@ -23,10 +23,6 @@ enum WorkshopRoute: Hashable, BundleRoute { case bundleNameEditView case bundleEditView - // MARK: - Festival 임시 라우트 (추후 정리 예정) - case showcase25BoardView - case festivalKeyringDetailView(Keyring) - // MARK: - 아크릴 포토 템플릿 case acrylicPhotoPreview case acrylicPhotoCrop From 00558380b57f4b110b29226d95b0f0c00a7c41f7 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 17:19:47 +0900 Subject: [PATCH 03/12] =?UTF-8?q?refactor:=20Festival=20=EB=B7=B0=EB=93=A4?= =?UTF-8?q?=20=ED=99=88=EB=A3=A8=ED=8A=B8=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Festival/Views/FestivalKeyringDetailView+Alerts.swift | 4 ++-- .../Festival/Views/FestivalKeyringDetailView.swift | 4 ++-- Keychy/Keychy/Presentation/Festival/Views/FestivalView.swift | 2 +- .../Views/Showcase25Board/Showcase25BoardView+Detail.swift | 2 +- .../Festival/Views/Showcase25Board/Showcase25BoardView.swift | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Keychy/Keychy/Presentation/Festival/Views/FestivalKeyringDetailView+Alerts.swift b/Keychy/Keychy/Presentation/Festival/Views/FestivalKeyringDetailView+Alerts.swift index d6593901..7922820c 100644 --- a/Keychy/Keychy/Presentation/Festival/Views/FestivalKeyringDetailView+Alerts.swift +++ b/Keychy/Keychy/Presentation/Festival/Views/FestivalKeyringDetailView+Alerts.swift @@ -74,8 +74,8 @@ extension FestivalKeyringDetailView { } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { isSheetPresented = false - - festivalRouter.push(.coinCharge) + + router.push(.coinCharge) } } ) diff --git a/Keychy/Keychy/Presentation/Festival/Views/FestivalKeyringDetailView.swift b/Keychy/Keychy/Presentation/Festival/Views/FestivalKeyringDetailView.swift index bf1e6b5b..3f0d6d29 100644 --- a/Keychy/Keychy/Presentation/Festival/Views/FestivalKeyringDetailView.swift +++ b/Keychy/Keychy/Presentation/Festival/Views/FestivalKeyringDetailView.swift @@ -11,7 +11,7 @@ import FirebaseFirestore import Photos struct FestivalKeyringDetailView: View { - @Bindable var festivalRouter: NavigationRouter + @Bindable var router: NavigationRouter @Bindable var viewModel: Showcase25BoardViewModel @State var userManager = UserManager.shared @@ -225,7 +225,7 @@ extension FestivalKeyringDetailView { // Leading (왼쪽) - 뒤로가기 버튼 BackToolbarButton { isSheetPresented = false - festivalRouter.pop() + router.pop() } } center: { // Center (중앙) - 빈 공간 diff --git a/Keychy/Keychy/Presentation/Festival/Views/FestivalView.swift b/Keychy/Keychy/Presentation/Festival/Views/FestivalView.swift index 6ae8fccf..74555069 100644 --- a/Keychy/Keychy/Presentation/Festival/Views/FestivalView.swift +++ b/Keychy/Keychy/Presentation/Festival/Views/FestivalView.swift @@ -11,7 +11,7 @@ import CoreLocation struct FestivalView: View { - @Bindable var router: NavigationRouter + @Bindable var router: NavigationRouter @State private var viewModel = FestivalViewModel() @State private var locationManager = LocationManager() diff --git a/Keychy/Keychy/Presentation/Festival/Views/Showcase25Board/Showcase25BoardView+Detail.swift b/Keychy/Keychy/Presentation/Festival/Views/Showcase25Board/Showcase25BoardView+Detail.swift index 9d8b8b49..610da91a 100644 --- a/Keychy/Keychy/Presentation/Festival/Views/Showcase25Board/Showcase25BoardView+Detail.swift +++ b/Keychy/Keychy/Presentation/Festival/Views/Showcase25Board/Showcase25BoardView+Detail.swift @@ -37,7 +37,7 @@ extension Showcase25BoardView { if let keyring = Keyring(documentId: document.documentID, data: data) { // 3. DetailView로 이동 (Main thread에서 실행) await MainActor.run { - festivalRouter.push(.festivalKeyringDetailView(keyring)) + router.push(.festivalKeyringDetailView(keyring)) } } else { print("Keyring 변환 실패") diff --git a/Keychy/Keychy/Presentation/Festival/Views/Showcase25Board/Showcase25BoardView.swift b/Keychy/Keychy/Presentation/Festival/Views/Showcase25Board/Showcase25BoardView.swift index dcd9ceb1..7c0baf5d 100644 --- a/Keychy/Keychy/Presentation/Festival/Views/Showcase25Board/Showcase25BoardView.swift +++ b/Keychy/Keychy/Presentation/Festival/Views/Showcase25Board/Showcase25BoardView.swift @@ -11,7 +11,7 @@ import FirebaseFirestore struct Showcase25BoardView: View { - @Bindable var festivalRouter: NavigationRouter + @Bindable var router: NavigationRouter @Bindable var viewModel: Showcase25BoardViewModel @Environment(\.scenePhase) private var scenePhase @@ -277,7 +277,7 @@ struct Showcase25BoardView: View { private var customNavigationBar: some View { CustomNavigationBar { BackToolbarButton { - festivalRouter.pop() + router.pop() } } center: { HStack { From fc653c6a600a451f209908465c64d60272c5e8ad Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 17:20:03 +0900 Subject: [PATCH 04/12] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=8E=98=EC=8A=A4=ED=8B=B0=EB=B2=8C=20=ED=83=AD,=20=EB=A3=A8?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 8 ---- .../Navigation/Routes/FestivalRoute.swift | 17 ------- .../Presentation/Tab/Views/FestivalTab.swift | 47 ------------------- 3 files changed, 72 deletions(-) delete mode 100644 Keychy/Keychy/Core/Navigation/Routes/FestivalRoute.swift delete mode 100644 Keychy/Keychy/Presentation/Tab/Views/FestivalTab.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index dc9b5cc5..0f2e92cc 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -293,7 +293,6 @@ 4CEBB14E2EFAA52F00CF53E2 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEBB14B2EFAA52F00CF53E2 /* DeepLinkManager.swift */; }; 4CEBB1552EFACFA900CF53E2 /* HomeTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEBB1512EFACFA900CF53E2 /* HomeTab.swift */; }; 4CEBB1572EFACFA900CF53E2 /* CollectionTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEBB14F2EFACFA900CF53E2 /* CollectionTab.swift */; }; - 4CEBB1582EFACFA900CF53E2 /* FestivalTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEBB1502EFACFA900CF53E2 /* FestivalTab.swift */; }; 4CEBB1592EFACFA900CF53E2 /* WorkshopTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEBB1532EFACFA900CF53E2 /* WorkshopTab.swift */; }; 4CEBB15F2EFAD3D600CF53E2 /* MainTabViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEBB15E2EFAD3D600CF53E2 /* MainTabViewModel.swift */; }; 4CEBB1612EFAD3F800CF53E2 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEBB1602EFAD3F800CF53E2 /* MainTabView.swift */; }; @@ -321,7 +320,6 @@ 4CEC62232EAE08DA0099ECEE /* KeyringRingComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEC61FE2EAE08DA0099ECEE /* KeyringRingComponent.swift */; }; 4CEC62252EAE08DA0099ECEE /* WorkshopRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEC62092EAE08DA0099ECEE /* WorkshopRoute.swift */; }; 4CEC62262EAE08DA0099ECEE /* Radius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEC62132EAE08DA0099ECEE /* Radius.swift */; }; - 4CEC62282EAE08DA0099ECEE /* FestivalRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEC620A2EAE08DA0099ECEE /* FestivalRoute.swift */; }; 4CEC62292EAE08DA0099ECEE /* KeyringCellScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEC61F32EAE08DA0099ECEE /* KeyringCellScene.swift */; }; 4CEC622A2EAE08DA0099ECEE /* Font+Custom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEC62102EAE08DA0099ECEE /* Font+Custom.swift */; }; 4CEC622B2EAE08DA0099ECEE /* Font+Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEC62112EAE08DA0099ECEE /* Font+Styles.swift */; }; @@ -742,7 +740,6 @@ 4CEBB14A2EFAA52F00CF53E2 /* DeepLinkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkHandler.swift; sourceTree = ""; }; 4CEBB14B2EFAA52F00CF53E2 /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; 4CEBB14F2EFACFA900CF53E2 /* CollectionTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionTab.swift; sourceTree = ""; }; - 4CEBB1502EFACFA900CF53E2 /* FestivalTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FestivalTab.swift; sourceTree = ""; }; 4CEBB1512EFACFA900CF53E2 /* HomeTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTab.swift; sourceTree = ""; }; 4CEBB1532EFACFA900CF53E2 /* WorkshopTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopTab.swift; sourceTree = ""; }; 4CEBB15E2EFAD3D600CF53E2 /* MainTabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabViewModel.swift; sourceTree = ""; }; @@ -776,7 +773,6 @@ 4CEC62072EAE08DA0099ECEE /* HomeRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeRoute.swift; sourceTree = ""; }; 4CEC62082EAE08DA0099ECEE /* CollectionRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionRoute.swift; sourceTree = ""; }; 4CEC62092EAE08DA0099ECEE /* WorkshopRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkshopRoute.swift; sourceTree = ""; }; - 4CEC620A2EAE08DA0099ECEE /* FestivalRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FestivalRoute.swift; sourceTree = ""; }; 4CEC620C2EAE08DA0099ECEE /* NavigationRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRouter.swift; sourceTree = ""; }; 4CEC62102EAE08DA0099ECEE /* Font+Custom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Custom.swift"; sourceTree = ""; }; 4CEC62112EAE08DA0099ECEE /* Font+Styles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Styles.swift"; sourceTree = ""; }; @@ -1661,7 +1657,6 @@ 4CEBB1512EFACFA900CF53E2 /* HomeTab.swift */, 4CEBB1532EFACFA900CF53E2 /* WorkshopTab.swift */, 4CEBB14F2EFACFA900CF53E2 /* CollectionTab.swift */, - 4CEBB1502EFACFA900CF53E2 /* FestivalTab.swift */, ); path = Views; sourceTree = ""; @@ -1866,7 +1861,6 @@ 4CEC62072EAE08DA0099ECEE /* HomeRoute.swift */, 4CEC62082EAE08DA0099ECEE /* CollectionRoute.swift */, 4CEC62092EAE08DA0099ECEE /* WorkshopRoute.swift */, - 4CEC620A2EAE08DA0099ECEE /* FestivalRoute.swift */, AA390CE42ECC60A700D87EEC /* BundleRoute.swift */, ); path = Routes; @@ -2496,7 +2490,6 @@ AA8C9B8C2F0F349500A352D2 /* BundleDetailView+Alert.swift in Sources */, 4CEC62252EAE08DA0099ECEE /* WorkshopRoute.swift in Sources */, 4CEC62262EAE08DA0099ECEE /* Radius.swift in Sources */, - 4CEC62282EAE08DA0099ECEE /* FestivalRoute.swift in Sources */, 4CEC62292EAE08DA0099ECEE /* KeyringCellScene.swift in Sources */, AA2146BB2F161D0C0048D40E /* BundleEditView+Initialization.swift in Sources */, 3828F5492EC4CCE400F1B040 /* CollectionView+SearchMode.swift in Sources */, @@ -2703,7 +2696,6 @@ 38173D0A2EB8AD7900E36F7E /* CategoryTabBarWithLongPress.swift in Sources */, 4CEBB1552EFACFA900CF53E2 /* HomeTab.swift in Sources */, 4CEBB1572EFACFA900CF53E2 /* CollectionTab.swift in Sources */, - 4CEBB1582EFACFA900CF53E2 /* FestivalTab.swift in Sources */, 4CEBB1592EFACFA900CF53E2 /* WorkshopTab.swift in Sources */, 38F832CD2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift in Sources */, 38A22A9D2EC27AC400B4C7C5 /* PackagedKeyringView+SaveImage.swift in Sources */, diff --git a/Keychy/Keychy/Core/Navigation/Routes/FestivalRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/FestivalRoute.swift deleted file mode 100644 index ce8b1602..00000000 --- a/Keychy/Keychy/Core/Navigation/Routes/FestivalRoute.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// HomeRoute.swift -// KeytschPrototype -// -// Created by 길지훈 on 10/16/25. -// - -/// 페스티벌 탭 -enum FestivalRoute: Hashable { - case festivalView - case showcase25BoardView - - case festivalKeyringDetailView(Keyring) - - case coinCharge - -} diff --git a/Keychy/Keychy/Presentation/Tab/Views/FestivalTab.swift b/Keychy/Keychy/Presentation/Tab/Views/FestivalTab.swift deleted file mode 100644 index 202e52d1..00000000 --- a/Keychy/Keychy/Presentation/Tab/Views/FestivalTab.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// FestivalTab.swift -// KeytschPrototype -// -// Created by 길지훈 on 10/16/25. -// - -import SwiftUI - -struct FestivalTab: View { - @Bindable var router: NavigationRouter - @Bindable var showcaseVM: Showcase25BoardViewModel - var onSwitchToKeyringMaker: ((WorkshopRoute) -> Void)? = nil - var onSwitchToWorkshop: ((WorkshopRoute) -> Void)? = nil - - var body: some View { - NavigationStack(path: $router.path) { - FestivalView(router: router) - .navigationDestination(for: FestivalRoute.self) { route in - switch route { - case .showcase25BoardView: - Showcase25BoardView( - festivalRouter: router, - viewModel: showcaseVM, - onNavigateToKeyringMaker: { route in - onSwitchToKeyringMaker?(route) - }, - onNavigateToWorkshop: { route in - onSwitchToWorkshop?(route) - } - ) - - case .festivalView: - FestivalView(router: router) - case .festivalKeyringDetailView(let keyring): - FestivalKeyringDetailView( - festivalRouter: router, - viewModel: showcaseVM, - keyring: keyring - ) - case .coinCharge: - CoinChargeView(router: router) - } - } - } - } -} From 4aeb335f56c33942d4f48e835bda9db09c31bdfb Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 17:20:26 +0900 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20HomeView=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 네비버튼 레이아웃 VStack으로 변경 --- .../Home/Views/Main/HomeView.swift | 54 ++++++++++--------- .../Presentation/Tab/Views/HomeTab.swift | 9 ++++ 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift index 34436799..ab38966c 100644 --- a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift @@ -137,33 +137,35 @@ extension HomeView { /// 상단 네비게이션 버튼들 private var navigationButtons: some View { - HStack(spacing: 10) { - Spacer() - - // 알림 및 마이페이지 버튼 그룹 - GlassEffectContainer { - HStack { - Button { - router.push(.alarmView) - } label: { - Image(userManager.hasUnreadNotifications ? "alarmSent" : "alarm") - } - .frame(width: 44, height: 44) - .glassEffectUnion(id: "mapOptions", namespace: unionNamespace) - .buttonStyle(.glass) - - Button { - router.push(.myPageView) - } label: { - Image(.myPageIcon) - .resizable() - .scaledToFit() - .frame(width: 30, height: 30) - + VStack(alignment: .trailing, spacing: 12) { + HStack(spacing: 10) { + Spacer() + + // 알림 및 마이페이지 버튼 그룹 + GlassEffectContainer { + HStack { + Button { + router.push(.alarmView) + } label: { + Image(userManager.hasUnreadNotifications ? "alarmSent" : "alarm") + } + .frame(width: 44, height: 44) + .glassEffectUnion(id: "mapOptions", namespace: unionNamespace) + .buttonStyle(.glass) + + Button { + router.push(.myPageView) + } label: { + Image(.myPageIcon) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + + } + .frame(width: 44, height: 44) + .glassEffectUnion(id: "mapOptions", namespace: unionNamespace) + .buttonStyle(.glass) } - .frame(width: 44, height: 44) - .glassEffectUnion(id: "mapOptions", namespace: unionNamespace) - .buttonStyle(.glass) } } } diff --git a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift index 1b6869e7..1b11cdcd 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift @@ -13,6 +13,7 @@ struct HomeTab: View { @State private var collectionViewModel = CollectionViewModel() @State private var bundleViewModel = BundleViewModel() @Bindable private var introViewModel = IntroViewModel() + @State private var festivalViewModel = Showcase25BoardViewModel() /// 배경 로드 완료 콜백 var onBackgroundLoaded: (() -> Void)? = nil @@ -59,6 +60,14 @@ struct HomeTab: View { IntroView(viewModel: introViewModel) case .termsAndPolicy: TermsView(router: router) + + // Festival + case .festivalView: + FestivalView(router: router) + case .showcase25BoardView: + Showcase25BoardView(router: router, viewModel: festivalViewModel) + case .festivalKeyringDetailView(let keyring): + FestivalKeyringDetailView(router: router, viewModel: festivalViewModel, keyring: keyring) } } } From 39c9c9f85028b36a76ab1b9ff7fbfe05da5a87b5 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 20:18:30 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=AD=89=EC=B9=98=20=EB=B3=80=EA=B2=BD=20=EB=93=9C=EB=A1=AD?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/Popup/BundleSwitchPopup.swift | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift diff --git a/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift b/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift new file mode 100644 index 00000000..8cb42639 --- /dev/null +++ b/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift @@ -0,0 +1,113 @@ +// +// BundleSwitchPopup.swift +// Keychy +// +// Created by Claude on 2/3/26. +// + +import SwiftUI + +/// 홈 화면에서 뭉치를 변경할 수 있는 드롭다운 팝업 +struct BundleSwitchPopup: View { + let bundles: [KeyringBundle] + let currentBundle: KeyringBundle? + let onSelect: (KeyringBundle) -> Void + + /// 대표 뭉치 (isMain == true) + private var mainBundle: KeyringBundle? { + bundles.first(where: { $0.isMain }) + } + + /// 선택 가능한 뭉치들 (대표 제외) + private var selectableBundles: [KeyringBundle] { + bundles.filter { !$0.isMain } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 대표 섹션 + if let main = mainBundle { + sectionHeader("대표") + bundleRow(bundle: main, isSelected: currentBundle?.documentId == main.documentId) + } + + // 선택 섹션 + if !selectableBundles.isEmpty { + sectionHeader("선택") + + ForEach(selectableBundles, id: \.documentId) { bundle in + bundleRow(bundle: bundle, isSelected: currentBundle?.documentId == bundle.documentId) + } + } + } + .padding(.vertical, 12) + .padding(.horizontal, 4) + .frame(width: 160) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 20)) + } + + // MARK: - Subviews + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .typography(.suit13M) + .foregroundStyle(.gray400) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 4) + } + + private func bundleRow(bundle: KeyringBundle, isSelected: Bool) -> some View { + Button { + onSelect(bundle) + } label: { + HStack { + Text(bundle.name) + .typography(isSelected ? .suit17B : .suit17M) + .foregroundStyle(isSelected ? .main500 : .black100) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.main500) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +// MARK: - 드롭다운 버튼 +struct BundleSwitchButton: View { + let bundleName: String + let isExpanded: Bool + let isEnabled: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 4) { + Text(bundleName) + .typography(.suit17SB) + .foregroundStyle(.black100) + + if isEnabled { + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.gray500) + .rotationEffect(.degrees(isExpanded ? 180 : 0)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .glassEffect(.regular.interactive(), in: .capsule) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + } +} From 89d71228b45e5a0465c4b7a0fee337fc458973ee Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 20:18:46 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20HomevIew=EC=97=90=20=EB=AD=89?= =?UTF-8?q?=EC=B9=98=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Views/Main/HomeView.swift | 93 +++++++++++++++++-- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift index ab38966c..bb3f36f3 100644 --- a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift @@ -27,6 +27,9 @@ struct HomeView: View { @State private var viewModel = HomeViewModel() @State private var isTabBarVisible = true + /// 뭉치 변경 팝업 표시 여부 + @State private var showBundleSwitchPopup = false + // MARK: - Body var body: some View { ZStack(alignment: .top) { @@ -59,8 +62,8 @@ struct HomeView: View { ) .ignoresSafeArea() /// 씬 재생성 조건을 위한 ID 설정 - /// 배경, 카라비너, 키링 구성이 변경되면 씬을 완전히 재생성 - .id("\(background.id ?? "")_\(carabiner.id ?? "")_\(viewModel.keyringDataList.map(\.index).sorted())") + /// 뭉치, 배경, 카라비너, 키링 구성이 변경되면 씬을 완전히 재생성 + .id("\(bundle.documentId ?? "")_\(background.id ?? "")_\(carabiner.id ?? "")_\(viewModel.keyringDataList.map(\.bodyImageURL).joined(separator: ","))") } else { // 데이터 로딩 중 Color.clear.ignoresSafeArea() @@ -76,6 +79,11 @@ struct HomeView: View { LoadingAlert(type: .longWithKeychy, message: "키링 뭉치를 불러오고 있어요") } } + + // 뭉치 변경 팝업 오버레이 + if showBundleSwitchPopup { + bundleSwitchPopupOverlay + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .toolbar(isTabBarVisible ? .visible : .hidden, for: .tabBar) @@ -99,11 +107,8 @@ struct HomeView: View { await viewModel.loadMainBundle(collectionViewModel: collectionViewModel, bundleViewModel: bundleViewModel, onBackgroundLoaded: onBackgroundLoaded) } .onChange(of: viewModel.keyringDataList) { _, _ in - // 키링 데이터가 변경되면 씬 준비 상태 초기화 + // 키링 데이터가 변경되면 씬 준비 상태 초기화 (씬이 자동으로 재생성됨) viewModel.handleKeyringDataChange() - Task { - await viewModel.loadMainBundle(collectionViewModel: collectionViewModel, bundleViewModel: bundleViewModel, onBackgroundLoaded: onBackgroundLoaded) - } } .onChange(of: NetworkManager.shared.isConnected) { oldValue, newValue in // 네트워크가 복구되고, 에러 상태였다면 자동 재시도 @@ -139,6 +144,9 @@ extension HomeView { private var navigationButtons: some View { VStack(alignment: .trailing, spacing: 12) { HStack(spacing: 10) { + // 뭉치 변경 버튼 (좌측) - 뭉치가 2개 이상일 때만 표시 + bundleSwitchButton + Spacer() // 알림 및 마이페이지 버튼 그룹 @@ -171,5 +179,78 @@ extension HomeView { } .padding(.horizontal, 20) } + + /// 뭉치 변경 버튼 + @ViewBuilder + private var bundleSwitchButton: some View { + let hasMutipleBundles = bundleViewModel.bundles.count >= 2 + let bundleName = bundleViewModel.selectedBundle?.name ?? "뭉치" + + BundleSwitchButton( + bundleName: bundleName, + isExpanded: showBundleSwitchPopup, + isEnabled: hasMutipleBundles, + onTap: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showBundleSwitchPopup.toggle() + } + } + ) + } + + /// 뭉치 변경 팝업 오버레이 + private var bundleSwitchPopupOverlay: some View { + ZStack(alignment: .topLeading) { + // 배경 탭하면 닫기 + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showBundleSwitchPopup = false + } + } + + // 팝업 (버튼 아래에 위치) + BundleSwitchPopup( + bundles: bundleViewModel.sortedBundles, + currentBundle: bundleViewModel.selectedBundle, + onSelect: { selectedBundle in + handleBundleSelection(selectedBundle) + } + ) + .padding(.top, getSafeAreaTop() + 50) + .padding(.leading, 20) + .transition(.scale(scale: 0.9, anchor: .topLeading).combined(with: .opacity)) + } + .ignoresSafeArea() + } + + /// 뭉치 선택 처리 + private func handleBundleSelection(_ bundle: KeyringBundle) { + // 같은 뭉치 선택 시 팝업만 닫기 + guard bundle.documentId != bundleViewModel.selectedBundle?.documentId else { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showBundleSwitchPopup = false + } + return + } + + // 팝업 닫기 + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showBundleSwitchPopup = false + } + + // 로딩 시작 + viewModel.isSceneReady = false + + // 선택된 뭉치로 변경 후 로드 + Task { + await viewModel.switchBundle( + to: bundle, + collectionViewModel: collectionViewModel, + bundleViewModel: bundleViewModel + ) + } + } } From cbf39940fdfedeede124ab9f717d0012c0034800 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 20:19:29 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20HomeVIewModel=EC=97=90=20switchBu?= =?UTF-8?q?ndle,=20updateMainBundle=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 뭉치 전환 시, 데이터 일괄 준비 후 일괄 업데이트! - loadMainBunlde 중복 호출 방지 --- .../Home/ViewModels/HomeViewModel.swift | 97 +++++++++++++++++-- 1 file changed, 88 insertions(+), 9 deletions(-) diff --git a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift index 393f40fd..974db5f3 100644 --- a/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift +++ b/Keychy/Keychy/Presentation/Home/ViewModels/HomeViewModel.swift @@ -37,6 +37,11 @@ class HomeViewModel { @MainActor func loadMainBundle(collectionViewModel: CollectionViewModel, bundleViewModel: BundleViewModel, onBackgroundLoaded: (() -> Void)?) async { + // 이미 데이터가 로드되었고 선택된 뭉치가 있으면 스킵 (탭 전환 후 돌아올 때) + if isDataLoaded && bundleViewModel.selectedBundle != nil { + return + } + let uid = UserManager.shared.userUID guard !uid.isEmpty else { return } @@ -178,12 +183,50 @@ class HomeViewModel { /// 번들의 배경을 Firebase에 업데이트 private func updateBundleBackground(documentId: String, backgroundId: String) async { + try? await db.collection("KeyringBundle").document(documentId).updateData([ + "selectedBackground": backgroundId + ]) + } + + /// 대표뭉치 설정 (isMain 업데이트) - Batch write로 원자성 보장 + /// - Parameters: + /// - newMainBundle: 새로 대표뭉치로 설정할 뭉치 + /// - bundleViewModel: BundleViewModel + @MainActor + private func updateMainBundle(newMainBundle: KeyringBundle, bundleViewModel: BundleViewModel) async { + guard let newMainDocId = newMainBundle.documentId else { return } + + let previousMainBundle = bundleViewModel.bundles.first(where: { $0.isMain }) + let batch = db.batch() + + // 1. 기존 대표뭉치 해제 (batch에 추가) + if let previousMain = previousMainBundle, + let previousDocId = previousMain.documentId, + previousDocId != newMainDocId { + let previousRef = db.collection("KeyringBundle").document(previousDocId) + batch.updateData(["isMain": false], forDocument: previousRef) + } + + // 2. 새 대표뭉치 설정 (batch에 추가) + let newMainRef = db.collection("KeyringBundle").document(newMainDocId) + batch.updateData(["isMain": true], forDocument: newMainRef) + + // 3. Batch commit (원자적 업데이트) do { - try await db.collection("KeyringBundle").document(documentId).updateData([ - "selectedBackground": backgroundId - ]) + try await batch.commit() + + // 성공 시 로컬 상태 업데이트 + if let previousMain = previousMainBundle, + let previousDocId = previousMain.documentId, + previousDocId != newMainDocId, + let index = bundleViewModel.bundles.firstIndex(where: { $0.documentId == previousDocId }) { + bundleViewModel.bundles[index].isMain = false + } + if let index = bundleViewModel.bundles.firstIndex(where: { $0.documentId == newMainDocId }) { + bundleViewModel.bundles[index].isMain = true + } } catch { - print("[HomeView] 뭉치 배경 업데이트 실패: \(error.localizedDescription)") + // 실패 시 로컬 상태는 변경하지 않음 (데이터 일관성 유지) } } @@ -196,13 +239,13 @@ class HomeViewModel { /// 모든 키링 준비 완료되면 0.5초 대기 후 로딩을 삭제함 func handleAllKeyringsReady() { - // 물리 엔진 안정화를 위한 딜레이만 적용 (0.5초) - Task { - try? await Task.sleep(for: .seconds(0.5)) // 0.5초 + Task { [weak self] in + try? await Task.sleep(for: .seconds(0.5)) - await MainActor.run { + await MainActor.run { [weak self] in + guard let self else { return } withAnimation(.easeOut(duration: 0.3)) { - isSceneReady = true + self.isSceneReady = true } } } @@ -216,4 +259,40 @@ class HomeViewModel { await loadMainBundle(collectionViewModel: collectionViewModel, bundleViewModel: bundleViewModel, onBackgroundLoaded: onBackgroundLoaded) } + // MARK: - Bundle Switching + + /// 다른 뭉치로 전환 + /// - Parameters: + /// - bundle: 전환할 뭉치 + /// - collectionViewModel: CollectionViewModel + /// - bundleViewModel: BundleViewModel + @MainActor + func switchBundle(to bundle: KeyringBundle, collectionViewModel: CollectionViewModel, bundleViewModel: BundleViewModel) async { + // 1. 씬 준비 상태 초기화 (로딩 효과 표시) + withAnimation(.easeIn(duration: 0.2)) { + isSceneReady = false + } + + // 2. 대표뭉치 설정 (isMain 업데이트) + await updateMainBundle(newMainBundle: bundle, bundleViewModel: bundleViewModel) + + // 3. 모든 데이터 먼저 준비 (UI 업데이트 전) + let resolvedBackground = bundleViewModel.resolveBackground(from: bundle.selectedBackground) + let resolvedCarabiner = bundleViewModel.resolveCarabiner(from: bundle.selectedCarabiner) + + guard let carabiner = resolvedCarabiner else { return } + + // 4. 키링 데이터 생성 (새 카라비너 기준) + let newKeyringDataList = await createKeyringDataList(bundle: bundle, carabiner: carabiner) + + // 5. 모든 상태를 한 번에 업데이트 (SwiftUI re-render 최소화) + bundleViewModel.selectedBundle = bundle + bundleViewModel.selectedBackground = resolvedBackground ?? bundleViewModel.backgrounds.first + bundleViewModel.selectedCarabiner = carabiner + keyringDataList = newKeyringDataList + + // 데이터 로드 완료 표시 + isDataLoaded = true + } + } From 2b8465869729a58188e0c690546e8c03ed4fb58f Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 20:20:14 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20MultiKeyringScene=20=EC=B9=B4?= =?UTF-8?q?=EB=9D=BC=EB=B9=84=EB=84=88=20=EB=A1=9C=EB=94=A9=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - beginLoading에서 weak self 적용 - loadCarabinerBack/Front 헬퍼 메서드 분리 - 씬 정리 중 콜백 방지 guard 추가 --- .../KeyringBundle/MultiKeyringScene.swift | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringScene.swift b/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringScene.swift index a4f0bfc8..c09ec0b9 100644 --- a/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringScene.swift +++ b/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringScene.swift @@ -181,35 +181,45 @@ class MultiKeyringScene: SKScene { } private func beginLoading() { - // 카라비너 이미지와 키링들을 로드 - Task { - async let carabinerBackTask: Void = { - if let carabinerBackURL = await carabinerBackImageURL { - if carabinerBackURL != "none" { - await setupCarabinerBackImageAsync(url: carabinerBackURL) - } - } - await MainActor.run { self.carabinerBackReady = true } - }() + Task { [weak self] in + guard let self else { return } + guard !self.isCleaningUp else { return } - async let carabinerFrontTask: Void = { - if let carabinerFrontURL = await carabinerFrontImageURL { - await setupCarabinerFrontImageAsync(url: carabinerFrontURL) - } - await MainActor.run { self.carabinerFrontReady = true } - }() + // 카라비너 이미지 병렬 로드 + async let backLoaded: Void = self.loadCarabinerBack() + async let frontLoaded: Void = self.loadCarabinerFront() - // 카라비너 이미지 로드 병렬 실행 - await carabinerBackTask - await carabinerFrontTask + await backLoaded + await frontLoaded // 키링 설정 (카라비너 준비 후) - await MainActor.run { + await MainActor.run { [weak self] in + guard let self, !self.isCleaningUp else { return } self.setupKeyringsIfNeeded() } } } + /// 카라비너 뒷면 이미지 로드 + private func loadCarabinerBack() async { + if let url = carabinerBackImageURL, url != "none" { + await setupCarabinerBackImageAsync(url: url) + } + await MainActor.run { [weak self] in + self?.carabinerBackReady = true + } + } + + /// 카라비너 앞면 이미지 로드 + private func loadCarabinerFront() async { + if let url = carabinerFrontImageURL { + await setupCarabinerFrontImageAsync(url: url) + } + await MainActor.run { [weak self] in + self?.carabinerFrontReady = true + } + } + // MARK: - Shadow Helper /// 노드에 수직 그림자 추가 (z축 위에서 내려오는 광원) @@ -328,7 +338,6 @@ class MultiKeyringScene: SKScene { private func setupKeyringsIfNeeded() { guard !didStartKeyringSetup else { return } - // 카라비너가 준비된 뒤에만 시작 guard carabinerBackReady && carabinerFrontReady else { return } didStartKeyringSetup = true setupKeyrings() From 47175a13e0226384162070b09788e9eeda85e722 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 20:20:44 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20MultiKeyringSceneView=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=94=AC=20=EC=83=9D=EC=84=B1=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeView .id() modifier가 뷰 재생성 담당 - 온체인지 제거 --- .../KeyringBundle/MultiKeyringSceneView.swift | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringSceneView.swift b/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringSceneView.swift index 10f9f3c0..1b0b686d 100644 --- a/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringSceneView.swift +++ b/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringSceneView.swift @@ -78,19 +78,11 @@ struct MultiKeyringSceneView: View { particleEffectsView } .onAppear { - // 씬이 없을 때 초기 설정 (키링이 없어도 배경과 카라비너는 표시) if scene == nil { loadBackgroundImage() setupScene() } } - .onChange(of: keyringDataList) { oldValue, newValue in - // 데이터가 실제로 변경된 경우에만 씬 재생성 - if oldValue != newValue { - loadBackgroundImage() - setupScene() - } - } .onChange(of: backgroundImageURL) { _, _ in loadBackgroundImage() } @@ -172,20 +164,18 @@ extension MultiKeyringSceneView { /// 씬 초기화 및 설정 private func setupScene() { - // 기존 씬이 있다면 명시적으로 정리 if scene != nil { cleanupScene() } - - // 키링 준비 카운터 리셋 + visibleKeyringCount = 0 - + let newScene = MultiKeyringScene( keyringDataList: keyringDataList, ringType: ringType, chainType: chainType, - backgroundColor: .clear, // 배경은 투명하게 - backgroundImageURL: nil, // 배경은 SwiftUI에서 처리 + backgroundColor: .clear, + backgroundImageURL: nil, carabinerBackImageURL: carabinerBackImageURL, carabinerFrontImageURL: carabinerFrontImageURL, carabinerX: carabinerX, From 82f6433f4d732ff00f86fd3bdafd94da68bf73b7 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 20:20:55 +0900 Subject: [PATCH 11/12] =?UTF-8?q?chore:=20=EA=B8=B0=ED=83=80=20=EB=94=94?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 4 ++++ .../Festival/ViewModels/Showcase25BoardViewModel.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 0f2e92cc..46bb0339 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + AA0000022F17000100000001 /* BundleSwitchPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000012F17000100000001 /* BundleSwitchPopup.swift */; }; 38173D082EB8AD3900E36F7E /* CollectionViewModel+Tags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38173D072EB8AD3900E36F7E /* CollectionViewModel+Tags.swift */; }; 38173D0A2EB8AD7900E36F7E /* CategoryTabBarWithLongPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38173D092EB8AD7900E36F7E /* CategoryTabBarWithLongPress.swift */; }; 38173D0C2EB8AD8800E36F7E /* CategoryContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38173D0B2EB8AD8800E36F7E /* CategoryContextMenu.swift */; }; @@ -462,6 +463,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AA0000012F17000100000001 /* BundleSwitchPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleSwitchPopup.swift; sourceTree = ""; }; 38173D072EB8AD3900E36F7E /* CollectionViewModel+Tags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionViewModel+Tags.swift"; sourceTree = ""; }; 38173D092EB8AD7900E36F7E /* CategoryTabBarWithLongPress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryTabBarWithLongPress.swift; sourceTree = ""; }; 38173D0B2EB8AD8800E36F7E /* CategoryContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryContextMenu.swift; sourceTree = ""; }; @@ -909,6 +911,7 @@ 38173D0D2EB902FD00E36F7E /* Popup */ = { isa = PBXGroup; children = ( + AA0000012F17000100000001 /* BundleSwitchPopup.swift */, 38173D0E2EB9083900E36F7E /* DeletePopup.swift */, 38173D102EB90CCE00E36F7E /* TagInputPopup.swift */, 3822DDBD2EBAAC80003125BE /* PurchasePopup.swift */, @@ -2501,6 +2504,7 @@ AA69DD262F14C60000C0A41C /* BundleViewModel+Edit.swift in Sources */, 4CC3D3C52EC701610009D376 /* IntroViewModel+Bundle.swift in Sources */, 38173D0F2EB9083900E36F7E /* DeletePopup.swift in Sources */, + AA0000022F17000100000001 /* BundleSwitchPopup.swift in Sources */, AA390CE52ECC60A700D87EEC /* BundleRoute.swift in Sources */, 38C3C27E2EC08794003C5DE1 /* PopupManager.swift in Sources */, C6C4028D2EB2741D006B58DF /* Sound.swift in Sources */, diff --git a/Keychy/Keychy/Presentation/Festival/ViewModels/Showcase25BoardViewModel.swift b/Keychy/Keychy/Presentation/Festival/ViewModels/Showcase25BoardViewModel.swift index 023d07da..e555877c 100644 --- a/Keychy/Keychy/Presentation/Festival/ViewModels/Showcase25BoardViewModel.swift +++ b/Keychy/Keychy/Presentation/Festival/ViewModels/Showcase25BoardViewModel.swift @@ -115,9 +115,9 @@ class Showcase25BoardViewModel { /// 실시간 리스너 중지 func stopListening() { + guard listener != nil else { return } listener?.remove() listener = nil - print("🛑 Stopped listening to ShowcaseFestivalKeyring") } // MARK: - 쇼케이스 키링 로드 From 6d8adef4c65c2116203e40aba840a9f82a8c5196 Mon Sep 17 00:00:00 2001 From: giljihun Date: Tue, 3 Feb 2026 20:58:08 +0900 Subject: [PATCH 12/12] =?UTF-8?q?style:=20=EB=AD=89=EC=B9=98=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B2=84=ED=8A=BC/=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=ED=95=98=EC=9D=B4=ED=8C=8C=EC=9D=B4=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/Popup/BundleSwitchPopup.swift | 115 +++++++++++------- 1 file changed, 71 insertions(+), 44 deletions(-) diff --git a/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift b/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift index 8cb42639..c7f1fec4 100644 --- a/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift +++ b/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift @@ -27,58 +27,83 @@ struct BundleSwitchPopup: View { VStack(alignment: .leading, spacing: 0) { // 대표 섹션 if let main = mainBundle { - sectionHeader("대표") - bundleRow(bundle: main, isSelected: currentBundle?.documentId == main.documentId) + mainSection(bundle: main) } + // 구분선 + Rectangle() + .fill(.gray100) + .frame(height: 1) + .padding(.horizontal, 18) + // 선택 섹션 if !selectableBundles.isEmpty { - sectionHeader("선택") + selectSection(bundles: selectableBundles) + } + } + .frame(width: 196) + .padding(.vertical, 5) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 34)) + } + + // MARK: - 대표 섹션 - ForEach(selectableBundles, id: \.documentId) { bundle in - bundleRow(bundle: bundle, isSelected: currentBundle?.documentId == bundle.documentId) + private func mainSection(bundle: KeyringBundle) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text("대표") + .typography(.suit13M) + .foregroundStyle(.gray200) + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 8) + + Button { + onSelect(bundle) + } label: { + HStack { + Text(bundle.name) + .typography(.suit16M) + .foregroundStyle(currentBundle?.documentId == bundle.documentId ? .gray600 : .gray400) + Spacer() } + .padding(.horizontal, 20) + .padding(.bottom, 12) + .contentShape(Rectangle()) } + .buttonStyle(.plain) } - .padding(.vertical, 12) - .padding(.horizontal, 4) - .frame(width: 160) - .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 20)) } - // MARK: - Subviews + // MARK: - 선택 섹션 - private func sectionHeader(_ title: String) -> some View { - Text(title) - .typography(.suit13M) - .foregroundStyle(.gray400) - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 4) - } + private func selectSection(bundles: [KeyringBundle]) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text("선택") + .typography(.suit13M) + .foregroundStyle(.gray200) + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 10) - private func bundleRow(bundle: KeyringBundle, isSelected: Bool) -> some View { - Button { - onSelect(bundle) - } label: { - HStack { - Text(bundle.name) - .typography(isSelected ? .suit17B : .suit17M) - .foregroundStyle(isSelected ? .main500 : .black100) - - Spacer() - - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.main500) + VStack(alignment: .leading, spacing: 25) { + ForEach(bundles, id: \.documentId) { bundle in + Button { + onSelect(bundle) + } label: { + HStack { + Text(bundle.name) + .typography(.suit16M) + .foregroundStyle(currentBundle?.documentId == bundle.documentId ? .gray600 : .gray400) + Spacer() + } + .padding(.horizontal, 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .contentShape(Rectangle()) + .padding(.bottom, 10) } - .buttonStyle(.plain) } } @@ -91,21 +116,23 @@ struct BundleSwitchButton: View { var body: some View { Button(action: onTap) { - HStack(spacing: 4) { + HStack(spacing: 12) { Text(bundleName) - .typography(.suit17SB) + .typography(.nanum24EB) .foregroundStyle(.black100) if isEnabled { Image(systemName: "chevron.down") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.gray500) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.black) .rotationEffect(.degrees(isExpanded ? 180 : 0)) + .offset(y: 0.5) + .frame(width: 24, height: 24) + .background(Color.white) + .clipShape(Circle()) + .overlay(Circle().stroke(Color(#colorLiteral(red: 0.8861967921, green: 0.8861967921, blue: 0.8861967921, alpha: 1)), lineWidth: 1)) } } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .glassEffect(.regular.interactive(), in: .capsule) } .buttonStyle(.plain) .disabled(!isEnabled)