diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 93e26e870..f7942fc2c 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ 4C25259C2F303745003CC5AD /* WidgetBundleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */; }; 4C25259D2F3037CD003CC5AD /* WidgetBundleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */; }; 4C25259E2F3037D6003CC5AD /* BundleImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B5707E2EC206CD0049F969 /* BundleImageCache.swift */; }; + 4C2525DA2F35B2A7003CC5AD /* BundleSheetFilterBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2525D92F35B2A7003CC5AD /* BundleSheetFilterBar.swift */; }; 4C3687F72EBFA87800C64E75 /* Pretendard-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */; }; 4C3687FA2EBFC0FB00C64E75 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */; }; 4C3687FC2EC05E6800C64E75 /* AccountAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */; }; @@ -362,7 +363,6 @@ AA3909462EC9F29500D87EEC /* UIApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */; }; AA39098E2ECA061700D87EEC /* GridItemSpacing.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA39098D2ECA061700D87EEC /* GridItemSpacing.swift */; }; AA390CE52ECC60A700D87EEC /* BundleRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA390CE42ECC60A700D87EEC /* BundleRoute.swift */; }; - AA4B07322EB26CD2005F9227 /* BundleAddKeyringView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4B07312EB26CD2005F9227 /* BundleAddKeyringView.swift */; }; AA6298522EC233D2001576C0 /* BundleEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6298512EC233D2001576C0 /* BundleEditView.swift */; }; AA6298542EC39065001576C0 /* BundleCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6298532EC39065001576C0 /* BundleCreateView.swift */; }; AA6298562EC3AD16001576C0 /* DraggableSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6298552EC3AD16001576C0 /* DraggableSheet.swift */; }; @@ -559,6 +559,7 @@ 4C004FBA2F19F1FE00D9063E /* RootViewModel+ReviewCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewModel+ReviewCheck.swift"; sourceTree = ""; }; 4C07024A2ECF10760026D6DC /* EffectSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EffectSyncManager.swift; sourceTree = ""; }; 4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBundleModel.swift; sourceTree = ""; }; + 4C2525D92F35B2A7003CC5AD /* BundleSheetFilterBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleSheetFilterBar.swift; sourceTree = ""; }; 4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-Medium.ttf"; sourceTree = ""; }; 4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAlert.swift; sourceTree = ""; }; @@ -813,7 +814,6 @@ AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Extension.swift"; sourceTree = ""; }; AA39098D2ECA061700D87EEC /* GridItemSpacing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridItemSpacing.swift; sourceTree = ""; }; AA390CE42ECC60A700D87EEC /* BundleRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleRoute.swift; sourceTree = ""; }; - AA4B07312EB26CD2005F9227 /* BundleAddKeyringView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleAddKeyringView.swift; sourceTree = ""; }; AA6298512EC233D2001576C0 /* BundleEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleEditView.swift; sourceTree = ""; }; AA6298532EC39065001576C0 /* BundleCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleCreateView.swift; sourceTree = ""; }; AA6298552EC3AD16001576C0 /* DraggableSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableSheet.swift; sourceTree = ""; }; @@ -1794,6 +1794,7 @@ AA2146B42F15D8490048D40E /* KeyringCell.swift */, AAA4467B2EC64C9900080AB1 /* SelectBackgroundSheet.swift */, AAA446812EC6519700080AB1 /* SelectCarabinerSheet.swift */, + 4C2525D92F35B2A7003CC5AD /* BundleSheetFilterBar.swift */, AA9115092EB1B7930026E9BC /* AddKeyringButton.swift */, AA6298572EC457DF001576C0 /* CarabinerPopup.swift */, F82FD6112F9442AAAD58DB97 /* BundleSheetToggleButtons.swift */, @@ -2117,7 +2118,6 @@ children = ( AA6298532EC39065001576C0 /* BundleCreateView.swift */, AA0219DD2EB1C041006EF269 /* BundleNameInputView.swift */, - AA4B07312EB26CD2005F9227 /* BundleAddKeyringView.swift */, ); path = Create; sourceTree = ""; @@ -2446,7 +2446,6 @@ files = ( C6C4028F2EB27458006B58DF /* Particle.swift in Sources */, AA0A54B72EC053E4007B5413 /* CarabinerType.swift in Sources */, - AA4B07322EB26CD2005F9227 /* BundleAddKeyringView.swift in Sources */, 4CEC61E52EAE08C00099ECEE /* KeyringBundle.swift in Sources */, 4C004FAD2F177F2600D9063E /* BundleDetailView+VideoGen.swift in Sources */, 386102462F10F9CE0045C529 /* KeyringReceiveView+Alerts.swift in Sources */, @@ -2740,6 +2739,7 @@ 4CEC627F2EAE08DF0099ECEE /* CollectionView.swift in Sources */, AA0A54B92EC05C41007B5413 /* BundleRingComponent.swift in Sources */, C6C361422ED44EE4009642F4 /* Showcase25BoardView+Sheet.swift in Sources */, + 4C2525DA2F35B2A7003CC5AD /* BundleSheetFilterBar.swift in Sources */, 3828F5452EC4CC0A00F1B040 /* CollectionViewModel+Filter.swift in Sources */, 38C3C28A2EC1D879003C5DE1 /* UnpackPopup.swift in Sources */, C6C361E42ED4AF48009642F4 /* Showcase25BoardView+Grid.swift in Sources */, diff --git a/Keychy/Keychy/Core/Components/View/Button/ToolbarButtons.swift b/Keychy/Keychy/Core/Components/View/Button/ToolbarButtons.swift index b029ada98..1b6a3c251 100644 --- a/Keychy/Keychy/Core/Components/View/Button/ToolbarButtons.swift +++ b/Keychy/Keychy/Core/Components/View/Button/ToolbarButtons.swift @@ -26,13 +26,16 @@ struct BackToolbarButton: View { // MARK: - Next Toolbar Button struct NextToolbarButton: View { let title: String + let isDisabled: Bool let action: () -> Void init( title: String = "다음", + isDisabled: Bool = false, action: @escaping () -> Void ) { self.title = title + self.isDisabled = isDisabled self.action = action } @@ -41,10 +44,11 @@ struct NextToolbarButton: View { Text(title) .typography(.suit17B) .padding(4) - .foregroundStyle(.black100) + .foregroundStyle(isDisabled ? .gray300 : .black100) } .frame(width: 62, height: 44) .glassEffect(.regular.interactive(), in: .capsule) + .disabled(isDisabled) } } diff --git a/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift b/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift index 5a1950af1..7fb780bab 100644 --- a/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift +++ b/Keychy/Keychy/Core/Components/View/Popup/BundleSwitchPopup.swift @@ -24,24 +24,28 @@ struct BundleSwitchPopup: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { - // 대표 섹션 - if let main = mainBundle { - mainSection(bundle: main) - } + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + // 대표 섹션 + if let main = mainBundle { + mainSection(bundle: main) + } - // 구분선 - Rectangle() - .fill(.gray100) - .frame(height: 1) - .padding(.horizontal, 18) + // 구분선 + Rectangle() + .fill(.gray100) + .frame(height: 1) + .padding(.horizontal, 18) - // 선택 섹션 - if !selectableBundles.isEmpty { - selectSection(bundles: selectableBundles) + // 선택 섹션 + if !selectableBundles.isEmpty { + selectSection(bundles: selectableBundles) + } } } + .scrollBounceBehavior(.basedOnSize) .frame(width: 196) + .frame(maxHeight: 270) .padding(.vertical, 5) .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 34)) } diff --git a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift index 2759f82d7..21023dc00 100644 --- a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift +++ b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift @@ -76,6 +76,7 @@ struct Typography { static let nanum17EB = Typography(font: .custom(.nanumExtraBold, size: 17), lineSpacing: 0) static let nanum16EB = Typography(font: .custom(.nanumExtraBold, size: 16), lineSpacing: 0) static let nanum12EB = Typography(font: .custom(.nanumExtraBold, size: 12), lineSpacing: 0) + static let nanum13EB = Typography(font: .custom(.nanumExtraBold, size: 13), lineSpacing: 0) static let nanum15EB25 = Typography(font: .custom(.nanumExtraBold, size: 15), lineSpacing: 10) static let nanum15B25 = Typography(font: .custom(.nanumBold, size: 15), lineSpacing: 10) diff --git a/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift index b8eb6d604..ddb56f523 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift @@ -12,7 +12,6 @@ protocol BundleRoute: Hashable { static var bundleInventoryView: Self { get } static var bundleDetailView: Self { get } static var bundleCreateView: Self { get } - static var bundleAddKeyringView: Self { get } static var bundleNameInputView: Self { get } static var bundleNameEditView: Self { get } static var bundleEditView: Self { get } diff --git a/Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift index 0080193b1..7d6ccf24f 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift @@ -19,7 +19,6 @@ enum CollectionRoute: Hashable, BundleRoute { case bundleInventoryView case bundleDetailView case bundleCreateView - case bundleAddKeyringView case bundleNameInputView case bundleNameEditView case bundleEditView diff --git a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift index 399675abb..e32b99fb1 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift @@ -11,7 +11,6 @@ enum HomeRoute: Hashable, BundleRoute { case bundleInventoryView case bundleDetailView case bundleCreateView - case bundleAddKeyringView case bundleNameInputView case bundleNameEditView case bundleEditView diff --git a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift index b0c7e8acb..2ca1788b5 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift @@ -18,7 +18,6 @@ enum WorkshopRoute: Hashable, BundleRoute { case bundleInventoryView case bundleDetailView case bundleCreateView - case bundleAddKeyringView case bundleNameInputView case bundleNameEditView case bundleEditView diff --git a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift index fc135d945..9e568b453 100644 --- a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift +++ b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel.swift @@ -75,6 +75,56 @@ class BundleViewModel { var isLoading = false var isPurchasing = false + // MARK: - 시트 필터/정렬 상태 + + var sheetSortOrder: String = "최신순" + var sheetShowFreeOnly: Bool = false + var sheetShowOwnedOnly: Bool = false + var showSheetSortSheet: Bool = false + + /// 시트 필터/정렬 상태 초기화 + func resetSheetFilterState() { + sheetSortOrder = "최신순" + sheetShowFreeOnly = false + sheetShowOwnedOnly = false + showSheetSortSheet = false + } + + /// 편집 화면 상태 초기화 (편집 화면 나갈 때 호출) + func resetEditState() { + newSelectedBackground = nil + newSelectedCarabiner = nil + selectedKeyrings = [:] + keyringOrder = [] + selectedKeyringPosition = 0 + } + + /// 현재 선택 상태를 UserDefaults에 임시 저장 (코인 충전 등 화면 이동 전) + func saveCurrentSelection() { + if let bg = newSelectedBackground { + UserDefaults.standard.set(bg.background.id, forKey: "tempSelectedBackgroundId") + } + if let cb = newSelectedCarabiner { + UserDefaults.standard.set(cb.carabiner.id, forKey: "tempSelectedCarabinerId") + } + } + + /// UserDefaults에서 선택 상태 복원 (복원 후 삭제) + func restoreSelectionIfNeeded() { + if let savedBackgroundId = UserDefaults.standard.string(forKey: "tempSelectedBackgroundId") { + if let restoredBackground = backgroundViewData.first(where: { $0.background.id == savedBackgroundId }) { + newSelectedBackground = restoredBackground + } + UserDefaults.standard.removeObject(forKey: "tempSelectedBackgroundId") + } + if let savedCarabinerId = UserDefaults.standard.string(forKey: "tempSelectedCarabinerId") { + if let restoredCarabiner = carabinerViewData.first(where: { $0.carabiner.id == savedCarabinerId }) { + newSelectedCarabiner = restoredCarabiner + } + UserDefaults.standard.removeObject(forKey: "tempSelectedCarabinerId") + } + } + // MARK: - 편집 화면용 데이터 var newSelectedBackground: BackgroundViewData? diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleAddKeyringView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleAddKeyringView.swift deleted file mode 100644 index cfa09c705..000000000 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleAddKeyringView.swift +++ /dev/null @@ -1,446 +0,0 @@ -// -// BundleAddKeyringView.swift -// Keychy -// -// Created by 김서현 on 10/28/25. -// - -import SwiftUI -import SpriteKit -import NukeUI - -/// 번들에 키링을 추가하는 뷰 -struct BundleAddKeyringView: View { - // MARK: - Properties - - @Bindable var router: NavigationRouter - @State var collectionVM: CollectionViewModel - @State var bundleVM: BundleViewModel - - @State private var showSelectKeyringSheet = false - @State private var selectedKeyrings: [Int: Keyring] = [:] - @State private var keyringOrder: [Int] = [] - @State private var selectedPosition = 0 - @State private var isCapturing = false - @State private var sceneRefreshId = UUID() - @State private var isSceneReady = false - // 키링 선택 시트 그리드 컬럼 - private let gridColumns: [GridItem] = [ - GridItem(.flexible(), spacing: 10), - GridItem(.flexible(), spacing: 10), - GridItem(.flexible(), spacing: 10) - ] - private let sheetHeightRatio: CGFloat = 0.43 - - // MARK: - Body - - var body: some View { - ZStack(alignment: .bottom) { - // 배경 + 카라비너 + 키링 씬 - ZStack(alignment: .top) { - if let carabiner = bundleVM.selectedCarabiner, - let background = bundleVM.selectedBackground { - keyringEditSceneView(background: background, carabiner: carabiner) - } - customNavigationBar - } - .blur(radius: !isCapturing ? 0 : 10) - - - // Dim 오버레이 (키링 시트가 열릴 때) - if showSelectKeyringSheet || isCapturing { - Color.black20 - .ignoresSafeArea() - .zIndex(1) - .onTapGesture { - if showSelectKeyringSheet { - withAnimation(.easeInOut) { - showSelectKeyringSheet = false - } - } - } - - // 키링 선택 시트 - keyringSelectionSheet() - .opacity(showSelectKeyringSheet ? 1 : 0) - - LoadingAlert(type: .longWithKeychy, message: "뭉치 만드는 중...") - .opacity(isCapturing ? 1 : 0) - .zIndex(50) - } - } - .ignoresSafeArea() - .onAppear { - fetchData() - TabBarManager.hide() - } - .navigationBarBackButtonHidden(true) - } -} - -// MARK: - Toolbar -extension BundleAddKeyringView { - private var customNavigationBar: some View { - CustomNavigationBar { - BackToolbarButton { - router.pop() - } - } center: { - - } trailing: { - NextToolbarButton { - Task { - await captureAndSaveScene() - } - } - .disabled(isCapturing) - } - } -} - -// MARK: - View Components -extension BundleAddKeyringView { - /// 키링 편집 씬 뷰 - private func keyringEditSceneView(background: Background, carabiner: Carabiner) -> some View { - ZStack { - // MultiKeyringScene - MultiKeyringSceneView( - keyringDataList: createKeyringDataList(carabiner: carabiner), - ringType: .basic, - chainType: .basic, - backgroundColor: .clear, - backgroundImageURL: background.backgroundImage, - carabinerBackImageURL: carabiner.backImageURL, - carabinerFrontImageURL: carabiner.frontImageURL, - carabinerX: carabiner.carabinerX, - carabinerY: carabiner.carabinerY, - carabinerWidth: carabiner.carabinerWidth, - currentCarabinerType: carabiner.type - ) - .ignoresSafeArea() - .id("scene_\(background.id ?? "bg")_\(carabiner.id ?? "cb")_\(selectedKeyrings.count)_\(sceneRefreshId.uuidString)") - - // 키링 추가 버튼들 - keyringButtons(carabiner: carabiner) - } - } - - /// 키링 추가 버튼들 - private func keyringButtons(carabiner: Carabiner) -> some View { - GeometryReader { geometry in - let sceneWidth: CGFloat = 402 - let sceneHeight: CGFloat = 874 - // 실제 화면 크기에 맞게 씬을 스케일링하는 비율 계산 - let scale = max(geometry.size.width / sceneWidth, geometry.size.height / sceneHeight) - - // 스케일 적용 후 콘텐츠 크기 - let contentW = sceneWidth * scale - let contentH = sceneHeight * scale - - // 씬을 화면 중앙에 배치하기 위한 오프셋 (실제 뷰 크기와 스케일된 씬의 크기 차이를 균등하게 배분) - let dx = (geometry.size.width - contentW) / 2 - let dy = (geometry.size.height - contentH) / 2 - - // 키링 추가 버튼들 - ForEach(0.. some View { - VStack(spacing: 18) { - Text("키링 선택") - .typography(.suit16B) - .foregroundStyle(.black100) - - if collectionVM.keyring.isEmpty { - VStack { - Image(.emptyViewIcon) - .resizable() - .scaledToFit() - .frame(height: 77) - Text("공방에서 키링을 만들 수 있어요") - .typography(.suit15R) - .foregroundStyle(.black100) - .padding(.vertical, 15) - } - .padding(.bottom, 77) - .padding(.top, 62) - .frame(maxWidth: .infinity) - - } else { - ScrollView { - LazyVGrid(columns: gridColumns, spacing: 10) { - ForEach(bundleVM.sortedKeyringsForSelection(selectedKeyrings: selectedKeyrings, selectedPosition: selectedPosition), id: \.self) { keyring in - keyringCell(keyring: keyring) - } - } - } - } - - } - .padding(EdgeInsets(top: 30, leading: 20, bottom: 0, trailing: 20)) - .frame(maxWidth: .infinity) - .frame(height: screenHeight * sheetHeightRatio) - .glassEffect(.regular, in: .rect) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .shadow(radius: 10) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .transition(.move(edge: .bottom)) - .zIndex(2) - } - - /// 키링 셀 (체크 토글 + 시트 유지) - private func keyringCell(keyring: Keyring) -> some View { - // 현재 선택된 위치에 이 키링이 선택되어 있는지 - let isSelectedHere: Bool = selectedKeyrings[selectedPosition]?.id == keyring.id - // 다른 위치에 이미 선택된 키링인지 체크 - let isSelectedElsewhere: Bool = selectedKeyrings.values.contains { $0.id == keyring.id } && !isSelectedHere - - return Button { - // 토글 - if isSelectedHere { - // 해제 - selectedKeyrings[selectedPosition] = nil - keyringOrder.removeAll { $0 == selectedPosition } - } else if !isSelectedElsewhere { - // 중복이 아닐 때만 선택 가능 - // 기존 있으면 순서 제거 후 교체 - if selectedKeyrings[selectedPosition] != nil { - keyringOrder.removeAll { $0 == selectedPosition } - } - selectedKeyrings[selectedPosition] = keyring - keyringOrder.append(selectedPosition) - // 키링 선택완료하면 시트 내림! - withAnimation(.easeInOut) { - showSelectKeyringSheet = false - } - } - // 중복인 경우 아무것도 하지 않음 (선택되지 않음) - updateKeyringDataList() - } label: { - ZStack(alignment: .bottomTrailing) { - VStack(spacing: 10) { - ZStack { - CollectionCellView(keyring: keyring) - .frame(width: threeGridCellWidth, height: threeGridCellHeight) - .cornerRadius(10) - - // 외곽선 - RoundedRectangle(cornerRadius: 10) - .strokeBorder(isSelectedHere ? .mainOpacity80 : .clear, lineWidth: 1.8) - .frame(width: threeGridCellWidth, height: threeGridCellHeight) - - // 다른 위치에 장착된 경우 오버레이 - if isSelectedElsewhere { - RoundedRectangle(cornerRadius: 10) - .fill(.black50) - .frame(width: threeGridCellWidth, height: threeGridCellHeight) - } - } - - Text("\(keyring.name)") - .typography(isSelectedHere ? .notosans14SB : .notosans14M) - .foregroundStyle(isSelectedHere ? .main500 : .black100) - .lineLimit(1) - .truncationMode(.tail) - } - - // 장착 중 아이콘 - if isSelectedElsewhere || isSelectedHere { - VStack { - HStack { - Spacer() - Text("장착 중") - .foregroundStyle(.white100) - .typography(.suit13M) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(.mainOpacity80) - ) - } - Spacer() - } - .padding(.top, 5) - .padding(.trailing, 5) - } - } - } - .disabled(keyring.status == .packaged || keyring.status == .published || isSelectedElsewhere) - .opacity(1.0) // 강제로 투명도 1.0 유지 - } - - - /// 키링 데이터 리스트 업데이트 - private func updateKeyringDataList() { - // 씬을 강제로 리프레시하여 키링 변경사항 즉시 반영 - sceneRefreshId = UUID() - } -} - -// MARK: - Scene Management - -extension BundleAddKeyringView { - /// 씬 캡처 및 저장 - private func captureAndSaveScene() async { - guard let carabiner = bundleVM.selectedCarabiner, - let background = bundleVM.selectedBackground else { - return - } - - // 캡처 시작 - await MainActor.run { - isCapturing = true - bundleVM.selectedKeyringsForBundle = selectedKeyrings - } - - // 배경 이미지 미리 로드 - guard let _ = try? await StorageManager.shared.getImage(path: background.backgroundImage) else { - await MainActor.run { - isCapturing = false - } - return - } - - // 캡처용 키링 데이터 생성 - var keyringDataList: [MultiKeyringCaptureScene.KeyringData] = [] - - for (index, keyring) in selectedKeyrings.sorted(by: { $0.key < $1.key }) { - let data = MultiKeyringCaptureScene.KeyringData( - index: index, - position: CGPoint( - x: carabiner.keyringXPosition[index], - y: carabiner.keyringYPosition[index] - ), - bodyImageURL: keyring.bodyImage, - hookOffsetY: keyring.hookOffsetY, - chainLength: keyring.chainLength - ) - keyringDataList.append(data) - } - - // 카라비너 이미지 추출 - let carabinerType = CarabinerType.from(carabiner.carabinerType) - let carabinerBackURL: String? - let carabinerFrontURL: String? - - if carabinerType == .hamburger { - carabinerBackURL = carabiner.carabinerImage[1] - carabinerFrontURL = carabiner.carabinerImage[2] - } else { - // plain 타입 - carabinerBackURL = carabiner.carabinerImage[0] - carabinerFrontURL = nil - } - - // 씬 캡처 - if let pngData = await MultiKeyringCaptureScene.captureBundleImage( - keyringDataList: keyringDataList, - backgroundImageURL: background.backgroundImage, - carabinerBackImageURL: carabinerBackURL, - carabinerFrontImageURL: carabinerFrontURL, - carabinerType: carabinerType, // 카라비너 타입 전달 - carabinerX: carabiner.carabinerX, - carabinerY: carabiner.carabinerY, - carabinerWidth: carabiner.carabinerWidth, - ) { - await MainActor.run { - bundleVM.bundleCapturedImage = pngData - } - } else { - print("❌ [BundleAddKeyring] 캡처 실패") - } - - // 캡처 완료 후 다음 화면으로 이동 - await MainActor.run { - isCapturing = false - router.push(.bundleNameInputView) - } - } -} - -// MARK: - Data Fetching - -extension BundleAddKeyringView { - /// 사용자 데이터 가져오기 - private func fetchData() { - let uid = UserManager.shared.userUID - - collectionVM.fetchUserCollectionData(uid: uid) { success in - if success { - collectionVM.fetchUserKeyrings(uid: uid) { success in - if success { - bundleVM.keyring = collectionVM.keyring - print("데이터 가져오기 성공") - } - } - } - } - } -} - -// MARK: - Helper Methods - -extension BundleAddKeyringView { - /// 키링 데이터 리스트 생성 - private func createKeyringDataList(carabiner: Carabiner) -> [MultiKeyringScene.KeyringData] { - var dataList: [MultiKeyringScene.KeyringData] = [] - - // 추가된 순서대로 처리 - for index in keyringOrder { - guard let keyring = selectedKeyrings[index] else { continue } - let soundId = keyring.soundId - - // 커스텀 사운드 URL 처리 - let customSoundURL: URL? = { - if soundId.hasPrefix("https://") || soundId.hasPrefix("http://") { - return URL(string: soundId) - } - return nil - }() - - let particleId = keyring.particleId - - // 절대 좌표 사용 (이미 절대 좌표로 저장됨) - let position = CGPoint( - x: carabiner.keyringXPosition[index], - y: carabiner.keyringYPosition[index] - ) - - let data = MultiKeyringScene.KeyringData( - index: index, - position: position, - bodyImageURL: keyring.bodyImage, - templateId: keyring.selectedTemplate, - soundId: soundId, - customSoundURL: customSoundURL, - particleId: particleId, - hookOffsetY: keyring.hookOffsetY, - chainLength: keyring.chainLength - ) - dataList.append(data) - } - - return dataList - } -} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift index 831ddaf91..15579717c 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView.swift @@ -18,13 +18,23 @@ struct BundleCreateView: View { @Bindable var bundleVM: BundleViewModel // 시트 활성화 상태 - @State private var showBackgroundSheet: Bool = false - @State private var showCarabinerSheet: Bool = false - - // 임시 초기값 + @State private var showItemSheet: Bool = false + @State private var isBackgroundMode: Bool = true // true: 배경, false: 카라비너 + @State private var showKeyringSheet: Bool = false + + // 시트 높이 @State private var sheetHeight: CGFloat = 360 - private let sheetHeightRatio: CGFloat = 0.5 - + + // 키링 선택 상태 + @State private var selectedKeyrings: [Int: Keyring] = [:] + @State private var keyringOrder: [Int] = [] + @State private var selectedPosition: Int = 0 + + // 캡처 상태 + @State private var isCapturing: Bool = false + @State private var sceneRefreshId = UUID() + @State private var isSceneReady: Bool = false + // 구매 시트 @State var showPurchaseSheet = false @@ -33,62 +43,88 @@ struct BundleCreateView: View { @State var purchasesSuccessScale: CGFloat = 0.3 @State var showPurchaseFailAlert = false @State var purchaseFailScale: CGFloat = 0.3 - - + // 공통 그리드 컬럼 (배경, 카라비너, 키링 모두 동일) private let gridColumns: [GridItem] = [ GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10) ] + + private let sheetHeightRatio: CGFloat = 0.5 //MARK: 메인 뷰 var body: some View { ZStack(alignment: .bottom) { if let bg = bundleVM.newSelectedBackground, let cb = bundleVM.newSelectedCarabiner { - // 배경과 카라비너만 보여줌 - MultiKeyringSceneView( - keyringDataList: [], - ringType: .basic, - chainType: .basic, - backgroundColor: .clear, - backgroundImageURL: bg.background.backgroundImage, - carabinerBackImageURL: cb.carabiner.backImageURL, - carabinerFrontImageURL: cb.carabiner.frontImageURL, - carabinerX: cb.carabiner.carabinerX, - carabinerY: cb.carabiner.carabinerY, - carabinerWidth: cb.carabiner.carabinerWidth, - currentCarabinerType: cb.carabiner.type - ) - .id("scene_\(bg.background.id ?? "bg")_\(cb.carabiner.id ?? "cb")") - .blur(radius: showPurchaseSuccessAlert ? 10 : 0) - - sheetContent() - .blur(radius: showPurchaseSuccessAlert ? 10 : 0) - + // 배경 + 카라비너 + 키링 씬 + ZStack { + MultiKeyringSceneView( + keyringDataList: createKeyringDataList(carabiner: cb.carabiner), + ringType: .basic, + chainType: .basic, + backgroundColor: .clear, + backgroundImageURL: bg.background.backgroundImage, + carabinerBackImageURL: cb.carabiner.backImageURL, + carabinerFrontImageURL: cb.carabiner.frontImageURL, + carabinerX: cb.carabiner.carabinerX, + carabinerY: cb.carabiner.carabinerY, + carabinerWidth: cb.carabiner.carabinerWidth, + currentCarabinerType: cb.carabiner.type, + onBackgroundLoaded: { + // 키링이 없으면 배경 로드 시 바로 준비 완료 + if selectedKeyrings.isEmpty { + withAnimation(.easeOut(duration: 0.3)) { + isSceneReady = true + } + } + }, + onAllKeyringsReady: { + withAnimation(.easeOut(duration: 0.3)) { + isSceneReady = true + } + } + ) + .id("scene_\(bg.background.id ?? "bg")_\(cb.carabiner.id ?? "cb")_\(selectedKeyrings.count)_\(sceneRefreshId.uuidString)") + + // 키링 추가 + 버튼들 + keyringButtons(carabiner: cb.carabiner) + } + .blur(radius: showPurchaseSuccessAlert || isCapturing ? 10 : 0) + + // 하단 셀렉터 + 시트 + sheetContent + .blur(radius: showPurchaseSuccessAlert || isCapturing ? 10 : 0) + customNavigationBar - .blur(radius: showPurchaseSuccessAlert ? 10 : 0) + .blur(radius: showPurchaseSuccessAlert || isCapturing ? 10 : 0) } - - - // Alert들, 컨텐츠가 화면의 중앙에 오도록 함 + + // 캡처 중 로딩 + if isCapturing { + Color.black20 + .ignoresSafeArea() + LoadingAlert(type: .longWithKeychy, message: "뭉치 만드는 중...") + } + + // Alert들 alertContent .position(x: screenWidth / 2, y: screenHeight / 2) - + + // 구매 시트 오버레이 ZStack { Color.black20 .ignoresSafeArea() .zIndex(10) - // 구매 시트 - 화면 맨 밑에 표시 - VStack { - Spacer() - purchaseSheetView - } - .zIndex(100) - .ignoresSafeArea() - .transition(.move(edge: .bottom)) - .animation(.easeInOut(duration: 0.3), value: showPurchaseSheet) + VStack { + Spacer() + purchaseSheetView + } + .zIndex(100) + .ignoresSafeArea() + .transition(.move(edge: .bottom)) + .animation(.easeInOut(duration: 0.3), value: showPurchaseSheet) } .opacity(showPurchaseSheet ? 1 : 0) .blur(radius: showPurchaseSuccessAlert ? 10 : 0) @@ -99,28 +135,30 @@ struct BundleCreateView: View { await initializeData() } .onAppear { - // 화면이 나타날 때마다 데이터 새로고침 Task { await refreshData() } TabBarManager.hide() - // 화면 첫 진입 시 배경 시트를 보여줌 - if !showBackgroundSheet && !showCarabinerSheet { - showBackgroundSheet = true - } + bundleVM.resetSheetFilterState() } - // 선택 타입이 배경화면이면 카라비너 시트는 닫고, 카라비너 열리면 배경화면은 닫힘 - .onChange(of: showBackgroundSheet) { oldValue, newValue in - if newValue { - showCarabinerSheet = false - } + .onDisappear { + bundleVM.resetEditState() } - .onChange(of: showCarabinerSheet) { oldValue, newValue in - if newValue { - showBackgroundSheet = false - } + .sheet(isPresented: $showKeyringSheet) { + keyringSheetContent + } + .sheet(isPresented: $bundleVM.showSheetSortSheet) { + sortSheetContent } } + + /// 정렬 선택 시트 + private var sortSheetContent: some View { + WorkshopSortSheet( + showSheet: $bundleVM.showSheetSortSheet, + sortOrder: $bundleVM.sheetSortOrder + ) + } } // MARK: - 커스텀 네비게이션 바 @@ -137,68 +175,201 @@ extension BundleCreateView { showPurchaseSheet = true } } else { - NextToolbarButton { - // ViewModel 상태를 selectedBackground/selectedCarabiner로도 동기화 - if let bg = bundleVM.newSelectedBackground { - bundleVM.selectedBackground = bg.background + NextToolbarButton(isDisabled: isCapturing || selectedKeyrings.isEmpty) { + Task { + await captureAndSaveScene() } - if let cb = bundleVM.newSelectedCarabiner { - bundleVM.selectedCarabiner = cb.carabiner - } - router.push(.bundleAddKeyringView) } } } - } } -//MARK: - 시트 뷰 + +// MARK: - 키링 + 버튼 +extension BundleCreateView { + private func keyringButtons(carabiner: Carabiner) -> some View { + GeometryReader { geometry in + let sceneWidth: CGFloat = 402 + let sceneHeight: CGFloat = 874 + let scale = max(geometry.size.width / sceneWidth, geometry.size.height / sceneHeight) + + let contentW = sceneWidth * scale + let contentH = sceneHeight * scale + + let dx = (geometry.size.width - contentW) / 2 + let dy = (geometry.size.height - contentH) / 2 + + ForEach(0.. some View { - Group { - // 배경 시트 - if showBackgroundSheet { + private var sheetContent: some View { + ZStack(alignment: .bottom) { + Color.clear + + if showItemSheet { + // 시트가 있을 때: 셀렉터 + 시트가 함께 움직임 VStack(spacing: 0) { - Spacer() BundleSheetToggleButtons( - showBackgroundSheet: $showBackgroundSheet, - showCarabinerSheet: $showCarabinerSheet + showItemSheet: $showItemSheet, + isBackgroundMode: $isBackgroundMode ) + .padding(.bottom, 10) + DraggableSheet( sheetHeight: $sheetHeight, - content: SelectBackgroundSheet( - viewModel: bundleVM, - selectedBG: bundleVM.newSelectedBackground, - onBackgroundTap: { bg in - bundleVM.newSelectedBackground = bg - } - ) + header: BundleSheetFilterBar(viewModel: bundleVM), + content: itemSheetContent, + onDismiss: { + showItemSheet = false + } ) } + .transition(.move(edge: .bottom)) + } else { + // 시트가 없을 때: 셀렉터만 하단에 고정 + BundleSheetToggleButtons( + showItemSheet: $showItemSheet, + isBackgroundMode: $isBackgroundMode + ) + .padding(.bottom, 50) + .transition(.identity) } + } + .animation(.easeInOut(duration: 0.25), value: showItemSheet) + } - // 카라비너 시트 - if showCarabinerSheet { - VStack(spacing: 0) { - Spacer() - BundleSheetToggleButtons( - showBackgroundSheet: $showBackgroundSheet, - showCarabinerSheet: $showCarabinerSheet - ) - DraggableSheet( - sheetHeight: $sheetHeight, - content: SelectCarabinerSheet( - viewModel: bundleVM, - selectedCarabiner: bundleVM.newSelectedCarabiner, - onCarabinerTap: { carabiner in - bundleVM.newSelectedCarabiner = carabiner - } - ) - ) + @ViewBuilder + private var itemSheetContent: some View { + if isBackgroundMode { + SelectBackgroundSheet( + viewModel: bundleVM, + selectedBG: bundleVM.newSelectedBackground, + onBackgroundTap: { bg in + bundleVM.newSelectedBackground = bg + } + ) + } else { + SelectCarabinerSheet( + viewModel: bundleVM, + selectedCarabiner: bundleVM.newSelectedCarabiner, + onCarabinerTap: { carabiner in + bundleVM.newSelectedCarabiner = carabiner + } + ) + } + } + + private var keyringSheetContent: some View { + VStack(spacing: 18) { + Text("키링 선택") + .typography(.suit16B) + .foregroundStyle(.black100) + .padding(.top, 20) + + if bundleVM.keyring.isEmpty { + VStack { + Image(.emptyViewIcon) + .resizable() + .scaledToFit() + .frame(height: 77) + Text("공방에서 키링을 만들 수 있어요") + .typography(.suit15R) + .foregroundStyle(.black100) + .padding(.vertical, 15) + } + .padding(.bottom, 77) + .padding(.top, 62) + .frame(maxWidth: .infinity) + } else { + ScrollView { + LazyVGrid(columns: gridColumns, spacing: 10) { + ForEach(bundleVM.sortedKeyringsForSelection(selectedKeyrings: selectedKeyrings, selectedPosition: selectedPosition), id: \.self) { keyring in + keyringCell(keyring: keyring) + } + } + .padding(.horizontal, 20) + } + } + } + .presentationDetents([.fraction(0.45), .fraction(0.85)]) + .presentationDragIndicator(.visible) + } + + private func keyringCell(keyring: Keyring) -> some View { + let isSelectedHere = selectedKeyrings[selectedPosition]?.id == keyring.id + let isSelectedElsewhere = selectedKeyrings.values.contains { $0.id == keyring.id } && !isSelectedHere + + return Button { + if isSelectedHere { + selectedKeyrings[selectedPosition] = nil + keyringOrder.removeAll { $0 == selectedPosition } + } else if !isSelectedElsewhere { + if selectedKeyrings[selectedPosition] != nil { + keyringOrder.removeAll { $0 == selectedPosition } + } + selectedKeyrings[selectedPosition] = keyring + keyringOrder.append(selectedPosition) + showKeyringSheet = false + } + sceneRefreshId = UUID() + } label: { + ZStack(alignment: .topTrailing) { + VStack(spacing: 10) { + ZStack { + CollectionCellView(keyring: keyring) + .frame(width: threeGridCellWidth, height: threeGridCellHeight) + .cornerRadius(10) + + RoundedRectangle(cornerRadius: 10) + .strokeBorder(isSelectedHere ? .mainOpacity80 : .clear, lineWidth: 1.8) + .frame(width: threeGridCellWidth, height: threeGridCellHeight) + + if isSelectedElsewhere { + RoundedRectangle(cornerRadius: 10) + .fill(.black50) + .frame(width: threeGridCellWidth, height: threeGridCellHeight) + } + } + + Text(keyring.name) + .typography(isSelectedHere ? .notosans14SB : .notosans14M) + .foregroundStyle(isSelectedHere ? .main500 : .black100) + .lineLimit(1) + .truncationMode(.tail) + } + + if isSelectedElsewhere || isSelectedHere { + Text("장착 중") + .foregroundStyle(.white100) + .typography(.suit13M) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 20).fill(.mainOpacity80)) + .padding(.top, 5) + .padding(.trailing, 5) } } } + .disabled(keyring.status == .packaged || keyring.status == .published || isSelectedElsewhere) } } @@ -251,26 +422,12 @@ extension BundleCreateView { guard let _ = UserManager.shared.currentUser else { return } - + + let uid = UserManager.shared.userUID + // 배경 데이터 로드 await withCheckedContinuation { continuation in bundleVM.fetchAllBackgrounds { _ in - if self.bundleVM.newSelectedBackground == nil { - // 공방에서 미리 선택된 배경이 있으면 해당 배경 선택 - if let preSelectedId = bundleVM.preSelectedBackgroundId { - self.bundleVM.newSelectedBackground = bundleVM.backgroundViewData.first { bg in - bg.background.id == preSelectedId - } - bundleVM.preSelectedBackgroundId = nil // 사용 후 초기화 - } - // 미리 선택된 배경이 없으면 "퍼플키치"를 기본으로 선택, 없으면 첫 번째 선택 - if self.bundleVM.newSelectedBackground == nil { - self.bundleVM.newSelectedBackground = bundleVM.backgroundViewData.first { bg in - bg.background.backgroundName == "퍼플키치" - } ?? bundleVM.backgroundViewData.first - } - } - continuation.resume() } } @@ -278,23 +435,60 @@ extension BundleCreateView { // 카라비너 데이터 로드 await withCheckedContinuation { continuation in bundleVM.fetchAllCarabiners { _ in - if self.bundleVM.newSelectedCarabiner == nil { - // 공방에서 미리 선택된 카라비너가 있으면 해당 카라비너 선택 - if let preSelectedId = bundleVM.preSelectedCarabinerId { - self.bundleVM.newSelectedCarabiner = bundleVM.carabinerViewData.first { cb in - cb.carabiner.id == preSelectedId + continuation.resume() + } + } + + // 코인 충전 후 복귀 시 저장된 선택 복원 + bundleVM.restoreSelectionIfNeeded() + + // 배경 선택 (복원된 값이 없을 때만) + if bundleVM.newSelectedBackground == nil { + // 공방에서 미리 선택된 배경이 있으면 해당 배경 선택 + if let preSelectedId = bundleVM.preSelectedBackgroundId { + bundleVM.newSelectedBackground = bundleVM.backgroundViewData.first { bg in + bg.background.id == preSelectedId + } + bundleVM.preSelectedBackgroundId = nil + } + // 미리 선택된 배경이 없으면 "퍼플키치"를 기본으로 선택 + if bundleVM.newSelectedBackground == nil { + bundleVM.newSelectedBackground = bundleVM.backgroundViewData.first { bg in + bg.background.backgroundName == "퍼플키치" + } ?? bundleVM.backgroundViewData.first + } + } + + // 카라비너 선택 (복원된 값이 없을 때만) + if bundleVM.newSelectedCarabiner == nil { + // 공방에서 미리 선택된 카라비너가 있으면 해당 카라비너 선택 + if let preSelectedId = bundleVM.preSelectedCarabinerId { + bundleVM.newSelectedCarabiner = bundleVM.carabinerViewData.first { cb in + cb.carabiner.id == preSelectedId + } + bundleVM.preSelectedCarabinerId = nil + } + // 미리 선택된 카라비너가 없으면 "웰컴 키치"를 기본으로 선택 + if bundleVM.newSelectedCarabiner == nil { + bundleVM.newSelectedCarabiner = bundleVM.carabinerViewData.first { cb in + cb.carabiner.carabinerName == "웰컴 키치" + } ?? bundleVM.carabinerViewData.first + } + } + + // 키링 데이터 로드 + await withCheckedContinuation { continuation in + collectionVM.fetchUserCollectionData(uid: uid) { success in + if success { + collectionVM.fetchUserKeyrings(uid: uid) { success in + if success { + bundleVM.keyring = collectionVM.keyring } - bundleVM.preSelectedCarabinerId = nil // 사용 후 초기화 - } - // 미리 선택된 카라비너가 없으면 "웰컴 키치"를 기본으로 선택, 없으면 첫 번째 선택 - if self.bundleVM.newSelectedCarabiner == nil { - self.bundleVM.newSelectedCarabiner = bundleVM.carabinerViewData.first { cb in - cb.carabiner.carabinerName == "웰컴 키치" - } ?? bundleVM.carabinerViewData.first + continuation.resume() } + } else { + continuation.resume() } - - continuation.resume() } } } @@ -311,13 +505,12 @@ extension BundleCreateView { .onTapGesture { showPurchaseSuccessAlert = false purchasesSuccessScale = 0.3 - router.push(.bundleAddKeyringView) } - + KeychyAlert(type: .checkmark, message: "구매가 완료되었어요!", isPresented: $showPurchaseSuccessAlert) .zIndex(101) } - + // 구매 실패 Alert if showPurchaseFailAlert { ZStack { @@ -327,7 +520,7 @@ extension BundleCreateView { showPurchaseFailAlert = false purchaseFailScale = 0.3 } - + PurchaseFailAlert( checkmarkScale: purchaseFailScale, onCancel: { @@ -337,6 +530,7 @@ extension BundleCreateView { onCharge: { showPurchaseFailAlert = false purchaseFailScale = 0.3 + bundleVM.saveCurrentSelection() router.push(.coinCharge) } ) @@ -371,10 +565,20 @@ extension BundleCreateView { // 구매할 아이템 목록 VStack(spacing: 20) { if let bg = bundleVM.newSelectedBackground, !bg.isOwned && bg.background.price > 0 { - BundlePurchaseCartItem(name: bg.background.backgroundName, type: "배경", price: bg.background.price) + BundlePurchaseCartItem( + imageURL: bg.background.backgroundImage, + name: bg.background.backgroundName, + type: "배경", + price: bg.background.price + ) } if let cb = bundleVM.newSelectedCarabiner, !cb.isOwned && cb.carabiner.price > 0 { - BundlePurchaseCartItem(name: cb.carabiner.carabinerName, type: "카라비너", price: cb.carabiner.price) + BundlePurchaseCartItem( + imageURL: cb.carabiner.carabinerImage.first ?? "", + name: cb.carabiner.carabinerName, + type: "카라비너", + price: cb.carabiner.price + ) } } .padding(.horizontal, 20) @@ -485,3 +689,123 @@ extension BundleCreateView { } } +// MARK: - 키링 데이터 및 캡처 +extension BundleCreateView { + /// 키링 데이터 리스트 생성 (씬 표시용) + private func createKeyringDataList(carabiner: Carabiner) -> [MultiKeyringScene.KeyringData] { + var dataList: [MultiKeyringScene.KeyringData] = [] + + for index in keyringOrder { + guard let keyring = selectedKeyrings[index] else { continue } + let soundId = keyring.soundId + + let customSoundURL: URL? = { + if soundId.hasPrefix("https://") || soundId.hasPrefix("http://") { + return URL(string: soundId) + } + return nil + }() + + let particleId = keyring.particleId + let position = CGPoint( + x: carabiner.keyringXPosition[index], + y: carabiner.keyringYPosition[index] + ) + + let data = MultiKeyringScene.KeyringData( + index: index, + position: position, + bodyImageURL: keyring.bodyImage, + templateId: keyring.selectedTemplate, + soundId: soundId, + customSoundURL: customSoundURL, + particleId: particleId, + hookOffsetY: keyring.hookOffsetY, + chainLength: keyring.chainLength + ) + dataList.append(data) + } + + return dataList + } + + /// 씬 캡처 및 저장 + private func captureAndSaveScene() async { + guard let cb = bundleVM.newSelectedCarabiner, + let bg = bundleVM.newSelectedBackground else { + return + } + + let carabiner = cb.carabiner + let background = bg.background + + // 캡처 시작 + await MainActor.run { + isCapturing = true + bundleVM.selectedKeyringsForBundle = selectedKeyrings + bundleVM.selectedBackground = background + bundleVM.selectedCarabiner = carabiner + } + + // 배경 이미지 미리 로드 + guard let _ = try? await StorageManager.shared.getImage(path: background.backgroundImage) else { + await MainActor.run { + isCapturing = false + } + return + } + + // 캡처용 키링 데이터 생성 + var keyringDataList: [MultiKeyringCaptureScene.KeyringData] = [] + + for (index, keyring) in selectedKeyrings.sorted(by: { $0.key < $1.key }) { + let data = MultiKeyringCaptureScene.KeyringData( + index: index, + position: CGPoint( + x: carabiner.keyringXPosition[index], + y: carabiner.keyringYPosition[index] + ), + bodyImageURL: keyring.bodyImage, + hookOffsetY: keyring.hookOffsetY, + chainLength: keyring.chainLength + ) + keyringDataList.append(data) + } + + // 카라비너 이미지 추출 + let carabinerType = CarabinerType.from(carabiner.carabinerType) + let carabinerBackURL: String? + let carabinerFrontURL: String? + + if carabinerType == .hamburger { + carabinerBackURL = carabiner.carabinerImage[1] + carabinerFrontURL = carabiner.carabinerImage[2] + } else { + carabinerBackURL = carabiner.carabinerImage[0] + carabinerFrontURL = nil + } + + // 씬 캡처 + if let pngData = await MultiKeyringCaptureScene.captureBundleImage( + keyringDataList: keyringDataList, + backgroundImageURL: background.backgroundImage, + carabinerBackImageURL: carabinerBackURL, + carabinerFrontImageURL: carabinerFrontURL, + carabinerType: carabinerType, + carabinerX: carabiner.carabinerX, + carabinerY: carabiner.carabinerY, + carabinerWidth: carabiner.carabinerWidth + ) { + await MainActor.run { + bundleVM.bundleCapturedImage = pngData + } + } + + // 캡처 완료 후 다음 화면으로 이동 + await MainActor.run { + isCapturing = false + router.push(.bundleNameInputView) + } + } +} + diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Initialization.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Initialization.swift index 52c0b7586..23e5c4c1f 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Initialization.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Initialization.swift @@ -37,21 +37,19 @@ extension BundleEditView { func loadBackgroundAndCarabiner() async { await withCheckedContinuation { continuation in bundleVM.fetchAllBackgrounds { _ in + // 현재 뭉치의 배경으로 항상 초기화 if let selectedBundle = bundleVM.selectedBundle { - if bundleVM.newSelectedBackground == nil { - bundleVM.newSelectedBackground = bundleVM.backgroundViewData.first { bgData in - bgData.background.id == selectedBundle.selectedBackground - } + bundleVM.newSelectedBackground = bundleVM.backgroundViewData.first { bgData in + bgData.background.id == selectedBundle.selectedBackground } } self.restoreBackgroundSelection() - + bundleVM.fetchAllCarabiners { _ in + // 현재 뭉치의 카라비너로 항상 초기화 if let selectedBundle = bundleVM.selectedBundle { - if bundleVM.newSelectedCarabiner == nil { - bundleVM.newSelectedCarabiner = bundleVM.carabinerViewData.first { cbData in - cbData.carabiner.id == selectedBundle.selectedCarabiner - } + bundleVM.newSelectedCarabiner = bundleVM.carabinerViewData.first { cbData in + cbData.carabiner.id == selectedBundle.selectedCarabiner } } self.restoreCarabinerSelection() diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Purchase.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Purchase.swift index e2775967b..84f8c1068 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Purchase.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Purchase.swift @@ -9,7 +9,7 @@ import SwiftUI extension BundleEditView { var purchaseSheetView: some View { - VStack(spacing: 12) { + VStack(spacing: 0) { // 상단 섹션 - 닫기 버튼, 타이틀 HStack { Button { @@ -26,15 +26,26 @@ extension BundleEditView { Spacer() } .padding(EdgeInsets(top: 30, leading: 20, bottom: 10, trailing: 20)) + .padding(.bottom, 12) // 구매할 아이템 목록 ScrollView { VStack(spacing: 20) { if let bg = bundleVM.newSelectedBackground, !bg.isOwned && bg.background.price > 0 { - BundlePurchaseCartItem(name: bg.background.backgroundName, type: "배경", price: bg.background.price) + BundlePurchaseCartItem( + imageURL: bg.background.backgroundImage, + name: bg.background.backgroundName, + type: "배경", + price: bg.background.price + ) } if let cb = bundleVM.newSelectedCarabiner, !cb.isOwned && cb.carabiner.price > 0 { - BundlePurchaseCartItem(name: cb.carabiner.carabinerName, type: "카라비너", price: cb.carabiner.price) + BundlePurchaseCartItem( + imageURL: cb.carabiner.carabinerImage.first ?? "", + name: cb.carabiner.carabinerName, + type: "카라비너", + price: cb.carabiner.price + ) } } .padding(.horizontal, 20) @@ -45,11 +56,13 @@ extension BundleEditView { Text("내 보유 : ") .typography(.suit15M25) .foregroundStyle(.black100) - .padding(.vertical, 4.5) Text("\(UserManager.shared.currentUser?.coin ?? 0)") .typography(.nanum16EB) .foregroundStyle(.main500) + .padding(.top, 2) } + .padding(.top, 20) + purchaseButton .padding(.horizontal, 33.2) .adaptiveBottomPadding() diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+SelectSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+SelectSheet.swift index 4ef7b9337..552032639 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+SelectSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+SelectSheet.swift @@ -9,47 +9,60 @@ import SwiftUI extension BundleEditView { var selectItemSheetContent: some View { - Group { - // 배경 시트 - VStack(spacing: 0) { - Spacer() - BundleSheetToggleButtons( - showBackgroundSheet: $showBackgroundSheet, - showCarabinerSheet: $showCarabinerSheet - ) - DraggableSheet( - sheetHeight: $sheetHeight, - content: SelectBackgroundSheet( - viewModel: bundleVM, - selectedBG: bundleVM.newSelectedBackground, - onBackgroundTap: { bg in - bundleVM.newSelectedBackground = bg - } + ZStack(alignment: .bottom) { + Color.clear + + if showItemSheet { + // 시트가 있을 때: 셀렉터 + 시트가 함께 움직임 + VStack(spacing: 0) { + BundleSheetToggleButtons( + showItemSheet: $showItemSheet, + isBackgroundMode: $isBackgroundMode ) - ) - } - .opacity(showBackgroundSheet ? 1 : 0) + .padding(.bottom, 10) - // 카라비너 시트 - VStack(spacing: 0) { - Spacer() - BundleSheetToggleButtons( - showBackgroundSheet: $showBackgroundSheet, - showCarabinerSheet: $showCarabinerSheet - ) - DraggableSheet( - sheetHeight: $sheetHeight, - content: SelectCarabinerSheet( - viewModel: bundleVM, - selectedCarabiner: bundleVM.newSelectedCarabiner, - onCarabinerTap: { carabiner in - selectCarabiner = carabiner - showChangeCarabinerAlert = true + DraggableSheet( + sheetHeight: $sheetHeight, + header: BundleSheetFilterBar(viewModel: bundleVM), + content: itemSheetContent, + onDismiss: { + showItemSheet = false } ) + } + .transition(.move(edge: .bottom)) + } else { + // 시트가 없을 때: 셀렉터만 하단에 고정 + BundleSheetToggleButtons( + showItemSheet: $showItemSheet, + isBackgroundMode: $isBackgroundMode ) + .padding(.bottom, 50) + .transition(.identity) } - .opacity(showCarabinerSheet ? 1 : 0) + } + .animation(.easeInOut(duration: 0.25), value: showItemSheet) + } + + @ViewBuilder + private var itemSheetContent: some View { + if isBackgroundMode { + SelectBackgroundSheet( + viewModel: bundleVM, + selectedBG: bundleVM.newSelectedBackground, + onBackgroundTap: { bg in + bundleVM.newSelectedBackground = bg + } + ) + } else { + SelectCarabinerSheet( + viewModel: bundleVM, + selectedCarabiner: bundleVM.newSelectedCarabiner, + onCarabinerTap: { carabiner in + selectCarabiner = carabiner + showChangeCarabinerAlert = true + } + ) } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift index d76a56dd5..a778ba165 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift @@ -25,8 +25,8 @@ struct BundleEditView: View { @State var isCapturing: Bool = false // MARK: - Sheet - @State var showBackgroundSheet: Bool = false - @State var showCarabinerSheet: Bool = false + @State var showItemSheet: Bool = false + @State var isBackgroundMode: Bool = true // true: 배경, false: 카라비너 @State var showPurchaseSheet = false @State var showSelectKeyringSheet = false @@ -81,6 +81,12 @@ struct BundleEditView: View { .sheet(isPresented: $showPurchaseSheet) { purchaseSheetView } + .sheet(isPresented: $bundleVM.showSheetSortSheet) { + WorkshopSortSheet( + showSheet: $bundleVM.showSheetSortSheet, + sortOrder: $bundleVM.sheetSortOrder + ) + } .navigationBarBackButtonHidden() .withToast(position: .default) .task { @@ -96,26 +102,19 @@ struct BundleEditView: View { await bundleVM.refreshEditData() } TabBarManager.hide() + bundleVM.resetSheetFilterState() // 화면 첫 진입 시 배경 시트를 보여줌 - if !showBackgroundSheet && !showCarabinerSheet { - showBackgroundSheet = true + if !showItemSheet { + isBackgroundMode = true + showItemSheet = true } } .onDisappear { isNavigatingAway = false + // 편집 화면 나갈 때 상태 초기화 + bundleVM.resetEditState() } .ignoresSafeArea() - // 배경 시트와 카라비너 시트는 동시에 열릴 수 없음 (하나가 열리면 다른 하나는 자동으로 닫힘) - .onChange(of: showBackgroundSheet) { oldValue, newValue in - if newValue { - showCarabinerSheet = false - } - } - .onChange(of: showCarabinerSheet) { oldValue, newValue in - if newValue { - showBackgroundSheet = false - } - } .onChange(of: bundleVM.newSelectedBackground) { _, newBackground in guard newBackground != nil else { return } // 배경 변경 시에는 키링 데이터 업데이트만 수행 (Firebase 접근 없음) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift index 2f84112cb..a1654583c 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BackgroundCell.swift @@ -13,7 +13,7 @@ struct BackgroundCell: View { let isSelected: Bool var body: some View { - VStack(spacing: 10) { + VStack(spacing: 6) { ZStack(alignment: .top) { // 배경 이미지 LazyImage(url: URL(string: background.background.backgroundImage)) { state in @@ -27,46 +27,62 @@ struct BackgroundCell: View { } } .frame(width: threeSquareGridCellSize, height: threeSquareGridCellSize) + .background(.white100) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay( RoundedRectangle(cornerRadius: 10) - .strokeBorder(isSelected ? .mainOpacity80 : .clear, lineWidth: 1.8) - + .strokeBorder(isSelected ? .main500 : .clear, lineWidth: 2) ) VStack { HStack { // 유료 아이콘 - Image(.paidIcon) - .padding(.top, 3) + Image(.myCoinMini) .opacity(background.background.isFree ? 0 : 1) Spacer() } + .padding(.horizontal, 8) + .padding(.top, 8) + Spacer() } - .padding(.top, 3) - .padding(.leading, 7) - VStack { - HStack { + // 오른쪽 상단: 유료 아이템만 표시 (보유/가격) + if !background.background.isFree { + VStack { + HStack { + Spacer() + if background.isOwned { + // 유료 + 보유 + Text("보유") + .typography(.suit12M) + .foregroundStyle(.white100) + .padding(.horizontal, 8) + .padding(.vertical, 1.5) + .background(.black60) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } else { + // 유료 + 미보유: 가격 표시 + Text("\(background.background.price)") + .typography(.nanum13EB) + .foregroundStyle(.white100) + .padding(.horizontal, 6) + .padding(.vertical, 2.25) + .padding(.top, 2) + .background(.mainOpacity80) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + } Spacer() - Text("보유") - .typography(.suit13M) - .foregroundStyle(.white100) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.black60) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .opacity((background.isOwned && !background.background.isFree) ? 1 : 0) } - Spacer() + .padding(.top, 7) + .padding(.trailing, 8) } - .padding(.top, 5) - .padding(.trailing, 7) } // 이름 라벨 Text(background.background.backgroundName) .typography(isSelected ? .notosans14SB : .notosans14M) .foregroundStyle(isSelected ? .main500 : .black100) } + .contentShape(Rectangle()) } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundlePurchaseCartItem.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundlePurchaseCartItem.swift index bca5d2969..4e456d73c 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundlePurchaseCartItem.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundlePurchaseCartItem.swift @@ -6,33 +6,73 @@ // import SwiftUI +import NukeUI /// 구매 시트에서 사용하는 장바구니 아이템 행 struct BundlePurchaseCartItem: View { + let imageURL: String let name: String let type: String let price: Int var body: some View { - HStack(spacing: 6) { - Image(.selectedIcon) + HStack(spacing: 12) { + // 아이템 썸네일 이미지 + if type == "카라비너" { + LazyImage(url: URL(string: imageURL)) { state in + if let image = state.image { + image + .resizable() + .scaledToFit() + } else if state.isLoading { + Color.gray50 + } else { + Color.gray50 + } + } + .padding(2) + .frame(width: 60, height: 60) + .background(.white100) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + LazyImage(url: URL(string: imageURL)) { state in + if let image = state.image { + image + .resizable() + .scaledToFill() + } else if state.isLoading { + Color.gray50 + } else { + Color.gray50 + } + } + .frame(width: 60, height: 60) + .background(.white100) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } - Text(name) - .typography(.suit16B) - .foregroundStyle(.black100) - .padding(.trailing, 7) + // 이름 + 카테고리 + VStack(alignment: .leading, spacing: 4) { + Text(name) + .typography(.suit16B) + .foregroundStyle(.black100) - Text(type) - .typography(.suit13M) - .foregroundStyle(.gray400) + Text(type) + .typography(.suit13M) + .foregroundStyle(.gray400) + } Spacer() - Text("\(price)") - .typography(.nanum16EB) - .foregroundStyle(.main500) + // 가격 + HStack(spacing: 4) { + Image(.myCoinMini) + Text("\(price)") + .typography(.nanum16EB) + .foregroundStyle(.main500) + } } - .padding(.vertical, 15) + .padding(.vertical, 12) .padding(.horizontal, 16) .background( RoundedRectangle(cornerRadius: 12) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSheetFilterBar.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSheetFilterBar.swift new file mode 100644 index 000000000..38807b093 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSheetFilterBar.swift @@ -0,0 +1,103 @@ +// +// BundleSheetFilterBar.swift +// Keychy +// +// Created by 길지훈 on 2/6/26. +// + +import SwiftUI + +/// 뭉치 생성/편집 시트에서 사용하는 필터바 +/// 공방과 달리 정렬 버튼에 배경색이 없음 +struct BundleSheetFilterBar: View { + @Bindable var viewModel: BundleViewModel + + var body: some View { + HStack(spacing: 0) { + // 정렬 버튼 (배경 없음) + sortButton + + Spacer() + + // 퀵 필터 버튼 (무료, 마이) + quickFilters + } + .padding(.horizontal, 20) + .padding(.top, 16) + .background(Color.clear) + .contentShape(Rectangle()) + } + + /// 정렬 버튼 (배경색 없음) + private var sortButton: some View { + Button { + viewModel.showSheetSortSheet = true + } label: { + HStack(spacing: 4) { + Text(viewModel.sheetSortOrder) + .typography(.suit14SB18) + .foregroundColor(.gray500) + + Image(systemName: "chevron.down") + .foregroundColor(.gray500) + } + .padding(.vertical, Spacing.sm) + .frame(height: 34) + } + .buttonStyle(PlainButtonStyle()) + } + + /// 퀵 필터 버튼 그룹 + private var quickFilters: some View { + HStack(spacing: 15) { + quickFilterButton(title: "무료", icon: nil, isSelected: viewModel.sheetShowFreeOnly) { + viewModel.sheetShowFreeOnly.toggle() + if viewModel.sheetShowFreeOnly { + viewModel.sheetShowOwnedOnly = false + } + } + + quickFilterButton(title: "마이", icon: .workshopOwnedIcon, isSelected: viewModel.sheetShowOwnedOnly) { + viewModel.sheetShowOwnedOnly.toggle() + if viewModel.sheetShowOwnedOnly { + viewModel.sheetShowFreeOnly = false + } + } + } + } + + /// 퀵 필터 버튼 + private func quickFilterButton( + title: String, + icon: ImageResource?, + isSelected: Bool, + action: @escaping () -> Void + ) -> some View { + Button { + action() + } label: { + HStack(spacing: 4) { + if let icon = icon { + Image(icon) + } + + HStack(spacing: 5) { + Text(title) + .typography(.suit14SB) + .foregroundColor(.gray500) + + Circle() + .fill(isSelected ? Color.main500 : Color.clear) + .stroke(.gray100, lineWidth: 1) + .frame(width: 16, height: 16) + .overlay { + if isSelected { + Image(.quickFilterChecked) + } + } + } + } + } + .buttonStyle(.plain) + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSheetToggleButtons.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSheetToggleButtons.swift index 1f6b87e83..d296e1150 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSheetToggleButtons.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSheetToggleButtons.swift @@ -9,8 +9,16 @@ import SwiftUI /// 뭉치 생성/편집 화면에서 배경/카라비너 시트를 토글하는 버튼 컴포넌트 struct BundleSheetToggleButtons: View { - @Binding var showBackgroundSheet: Bool - @Binding var showCarabinerSheet: Bool + @Binding var showItemSheet: Bool + @Binding var isBackgroundMode: Bool // true: 배경, false: 카라비너 + + private var isBackgroundSelected: Bool { + showItemSheet && isBackgroundMode + } + + private var isCarabinerSelected: Bool { + showItemSheet && !isBackgroundMode + } var body: some View { HStack(spacing: 8) { @@ -19,43 +27,60 @@ struct BundleSheetToggleButtons: View { Spacer() } .padding(.leading, 18) - .padding(.bottom, 10) } private var backgroundButton: some View { Button { - showBackgroundSheet = true + if isBackgroundSelected { + // 배경 모드에서 다시 누르면 시트 닫기 + showItemSheet = false + } else { + // 시트 열기 + 배경 모드로 전환 + isBackgroundMode = true + showItemSheet = true + } } label: { VStack(spacing: 0) { - Image(showBackgroundSheet ? .backgroundIconWhite100 : .backgroundIconGray600) + Image(isBackgroundSelected ? .backgroundIconWhite100 : .backgroundIconGray400) Text("배경") .typography(.suit9SB) - .foregroundStyle(showBackgroundSheet ? .white100 : .gray600) + .foregroundStyle(isBackgroundSelected ? .white100 : .gray400) } .frame(width: 46, height: 46) .background( RoundedRectangle(cornerRadius: 14.38) - .fill(showBackgroundSheet ? .main500 : .white100) + .fill(isBackgroundSelected ? .main500 : .white100) ) + .animation(nil, value: showItemSheet) + .animation(nil, value: isBackgroundMode) } .buttonStyle(.plain) } private var carabinerButton: some View { Button { - showCarabinerSheet = true + if isCarabinerSelected { + // 카라비너 모드에서 다시 누르면 시트 닫기 + showItemSheet = false + } else { + // 시트 열기 + 카라비너 모드로 전환 + isBackgroundMode = false + showItemSheet = true + } } label: { VStack(spacing: 0) { - Image(showCarabinerSheet ? .carabinerIconWhite100 : .carabinerIconGray600) + Image(isCarabinerSelected ? .carabinerIconWhite100 : .carabinerIconGray400) Text("카라비너") .typography(.suit9SB) - .foregroundStyle(showCarabinerSheet ? .white100 : .gray600) + .foregroundStyle(isCarabinerSelected ? .white100 : .gray400) } .frame(width: 46, height: 46) .background( RoundedRectangle(cornerRadius: 14.38) - .fill(showCarabinerSheet ? .main500 : .white100) + .fill(isCarabinerSelected ? .main500 : .white100) ) + .animation(nil, value: showItemSheet) + .animation(nil, value: isBackgroundMode) } .buttonStyle(.plain) } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift index 5e34e92e2..2531edaa2 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/CarabinerCell.swift @@ -13,7 +13,7 @@ struct CarabinerCell: View { var isSelected: Bool var body: some View { - VStack(spacing: 10) { + VStack(spacing: 6) { ZStack(alignment: .topLeading) { // 카라비너 이미지 LazyImage(url: URL(string: carabiner.carabiner.carabinerImage[0])) { state in @@ -32,47 +32,63 @@ struct CarabinerCell: View { } .padding(3.55) .frame(width: threeSquareGridCellSize, height: threeSquareGridCellSize) + .background(.white100) + .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay( RoundedRectangle(cornerRadius: 10) .strokeBorder(isSelected ? .mainOpacity80 : .clear, lineWidth: 1.8) - ) // 유료 재화 표시 VStack { HStack { // 유료 아이콘 - Image(.paidIcon) - .padding(.top, 3) + Image(.myCoinMini) .opacity(carabiner.carabiner.isFree ? 0 : 1) Spacer() } + .padding(.horizontal, 8) + .padding(.top, 8) Spacer() } - .padding(.top, 3) - .padding(.leading, 7) - VStack { - HStack { + // 오른쪽 상단: 유료 아이템만 표시 (보유/가격) + if !carabiner.carabiner.isFree { + VStack { + HStack { + Spacer() + if carabiner.isOwned { + // 유료 + 보유 + Text("보유") + .typography(.suit12M) + .foregroundStyle(.white100) + .padding(.horizontal, 8) + .padding(.vertical, 1.5) + .background(.black60) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } else { + // 유료 + 미보유: 가격 표시 + Text("\(carabiner.carabiner.price)") + .typography(.nanum13EB) + .foregroundStyle(.white100) + .padding(.horizontal, 6) + .padding(.vertical, 4.25) + .padding(.top, 2) + .background(.mainOpacity80) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + } Spacer() - Text("보유") - .typography(.suit13M) - .foregroundStyle(.white100) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.black60) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .opacity((carabiner.isOwned && !carabiner.carabiner.isFree) ? 1 : 0) } - Spacer() + .padding(.top, 8) + .padding(.trailing, 8) } - .padding(.top, 5) - .padding(.trailing, 7) } //: ZSTACK .clipped() Text(carabiner.carabiner.carabinerName) .typography(isSelected ? .notosans14SB : .notosans14M) .foregroundStyle(isSelected ? .main500 : .black100) } + .contentShape(Rectangle()) } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/DraggableSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/DraggableSheet.swift index 1dfe5839a..da927bbf4 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/DraggableSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/DraggableSheet.swift @@ -7,65 +7,61 @@ import SwiftUI -struct DraggableSheet: View { +struct DraggableSheet: View { @Binding var sheetHeight: CGFloat + let header: Header let content: Content - + var onDismiss: (() -> Void)? = nil + // 화면 높이 기준 비율 - private let smallRatio: CGFloat = 0.08 - private let mediumRatio: CGFloat = 0.32 - private let largeRatio: CGFloat = 0.8 + private let dismissRatio: CGFloat = 0.15 + private let mediumRatio: CGFloat = 0.40 + private let largeRatio: CGFloat = 0.80 // 계산된 높이 값들 - private var smallHeight: CGFloat { - screenHeight * smallRatio + private var dismissHeight: CGFloat { + screenHeight * dismissRatio } - + private var mediumHeight: CGFloat { screenHeight * mediumRatio } - + private var largeHeight: CGFloat { screenHeight * largeRatio } var body: some View { - VStack(spacing: 10) { - // 인디케이터 - VStack(spacing:0) { - RoundedRectangle(cornerRadius: 3) - .fill(.gray300) - .frame(width: 36, height: 5) - .padding(6) - Text("선택") - .typography(.suit16B) - .foregroundStyle(.black100) - .padding(EdgeInsets(top: 14, leading: 0, bottom: 12, trailing: 0)) - } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - .highPriorityGesture(dragGesture) - + VStack(spacing: 0) { + // 인디케이터 (터치 영역은 인디케이터 주변만) + RoundedRectangle(cornerRadius: 3) + .fill(.gray100) + .frame(width: 40, height: 4) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .contentShape(Rectangle()) + .highPriorityGesture(dragGesture) + + // 고정 헤더 (스크롤 안 됨) + header + .padding(.bottom, 10) + + // 스크롤 콘텐츠 ScrollView { content - .frame(maxWidth: .infinity, maxHeight: .infinity) } .scrollContentBackground(.hidden) - .safeAreaPadding(.top, 0) // ScrollView 상단 패딩 제거 - .gesture(dragGesture) } .frame(height: sheetHeight) .background( - RoundedRectangle(cornerRadius: 30) + RoundedRectangle(cornerRadius: 34) .stroke(.gray50, lineWidth: 1) .shadow(color: .black15, radius: 9, x: 0, y: 0) ) .glassEffect(.regular, in: .rect) .clipShape(RoundedRectangle(cornerRadius: 30)) .onAppear { - if sheetHeight == 360 { - sheetHeight = mediumHeight // 기본값을 중간 크기로 설정 - } + sheetHeight = mediumHeight } } @@ -73,22 +69,26 @@ struct DraggableSheet: View { DragGesture() .onChanged { value in let newHeight = sheetHeight - value.translation.height - if newHeight >= smallHeight && newHeight <= largeHeight { + // 0 이상, largeHeight 이하까지 드래그 가능 + if newHeight >= 0 && newHeight <= largeHeight { sheetHeight = newHeight } } .onEnded { _ in - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - // 3단계로 스냅 - let midSmallMedium = (smallHeight + mediumHeight) / 2 - let midMediumLarge = (mediumHeight + largeHeight) / 2 - - if sheetHeight < midSmallMedium { - sheetHeight = smallHeight - } else if sheetHeight < midMediumLarge { - sheetHeight = mediumHeight - } else { - sheetHeight = largeHeight + // 내리면 닫기 (거의 끝까지 내려야 닫힘) + let dismissThreshold = dismissHeight + (mediumHeight - dismissHeight) * 0.05 + let midMediumLarge = (mediumHeight + largeHeight) / 2 + + if sheetHeight < dismissThreshold { + // dismiss는 호출하는 쪽에서 애니메이션 제어 + onDismiss?() + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + if sheetHeight < midMediumLarge { + sheetHeight = mediumHeight + } else { + sheetHeight = largeHeight + } } } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift index 427f6ef9e..4e3935234 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectBackgroundSheet.swift @@ -8,40 +8,62 @@ import SwiftUI struct SelectBackgroundSheet: View { - let viewModel: BundleViewModel + @Bindable var viewModel: BundleViewModel let selectedBG: BackgroundViewData? let onBackgroundTap: (BackgroundViewData) -> Void - + /// 3열 그리드 컬럼 설정 private let gridColumns: [GridItem] = [ GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10) ] - - /// "키치 배경"을 맨 앞으로 정렬 - private var sortedBackgrounds: [BackgroundViewData] { - viewModel.backgroundViewData.sorted { bg1, bg2 in + + /// 필터링 및 정렬된 배경 목록 + private var filteredAndSortedBackgrounds: [BackgroundViewData] { + var result = viewModel.backgroundViewData + + // 필터 적용 + if viewModel.sheetShowFreeOnly { + result = result.filter { $0.background.isFree } + } else if viewModel.sheetShowOwnedOnly { + result = result.filter { $0.isOwned } + } + + // 정렬 + result = result.sorted { bg1, bg2 in + // 키치 배경은 항상 맨 앞 let isKeychy1 = bg1.background.backgroundName == "키치 배경" let isKeychy2 = bg2.background.backgroundName == "키치 배경" - + if isKeychy1 && !isKeychy2 { - return true // bg1이 앞으로 + return true } else if !isKeychy1 && isKeychy2 { - return false // bg2가 앞으로 - } else { - return false // 순서 유지 + return false + } + + // 정렬 기준 적용 + switch viewModel.sheetSortOrder { + case "최신순": + return bg1.background.createdAt > bg2.background.createdAt + case "인기순": + return bg1.background.useCount > bg2.background.useCount + default: + return false } } + + return result } - + var body: some View { - LazyVGrid(columns: gridColumns, spacing: 10) { - ForEach(sortedBackgrounds) { bg in + // 그리드만 (필터바는 DraggableSheet header로 이동) + LazyVGrid(columns: gridColumns, spacing: 20) { + ForEach(filteredAndSortedBackgrounds) { bg in BackgroundCell(background: bg, isSelected: (bg == selectedBG)) .onTapGesture { onBackgroundTap(bg) - + // 무료이고, 유저가 보유x인 경우에만 바로 추가 if !bg.isOwned && bg.background.isFree { Task { diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift index 5826b45f0..4c4fc3ea2 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/SelectCarabinerSheet.swift @@ -8,40 +8,62 @@ import SwiftUI struct SelectCarabinerSheet: View { - let viewModel: BundleViewModel + @Bindable var viewModel: BundleViewModel let selectedCarabiner: CarabinerViewData? let onCarabinerTap: (CarabinerViewData) -> Void - + /// 3열 그리드 컬럼 설정 private let gridColumns: [GridItem] = [ GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10) ] - - /// "키치 카라비너"를 맨 앞으로 정렬 - private var sortedCarabiners: [CarabinerViewData] { - viewModel.carabinerViewData.sorted { cb1, cb2 in + + /// 필터링 및 정렬된 카라비너 목록 + private var filteredAndSortedCarabiners: [CarabinerViewData] { + var result = viewModel.carabinerViewData + + // 필터 적용 + if viewModel.sheetShowFreeOnly { + result = result.filter { $0.carabiner.isFree } + } else if viewModel.sheetShowOwnedOnly { + result = result.filter { $0.isOwned } + } + + // 정렬 + result = result.sorted { cb1, cb2 in + // 키치 카라비너는 항상 맨 앞 let isKeychy1 = cb1.carabiner.carabinerName == "키치 카라비너" let isKeychy2 = cb2.carabiner.carabinerName == "키치 카라비너" - + if isKeychy1 && !isKeychy2 { - return true // cb1이 앞으로 + return true } else if !isKeychy1 && isKeychy2 { - return false // cb2가 앞으로 - } else { - return false // 순서 유지 + return false + } + + // 정렬 기준 적용 + switch viewModel.sheetSortOrder { + case "최신순": + return cb1.carabiner.createdAt > cb2.carabiner.createdAt + case "인기순": + return cb1.carabiner.useCount > cb2.carabiner.useCount + default: + return false } } + + return result } - + var body: some View { - LazyVGrid(columns: gridColumns, spacing: 10) { - ForEach(sortedCarabiners) { cb in + // 그리드만 (필터바는 DraggableSheet header로 이동) + LazyVGrid(columns: gridColumns, spacing: 20) { + ForEach(filteredAndSortedCarabiners) { cb in CarabinerCell(carabiner: cb, isSelected: (selectedCarabiner == cb)) .onTapGesture { onCarabinerTap(cb) - + if !cb.isOwned && cb.carabiner.isFree { Task { await viewModel.addCarabinerToUser(carabinerName: cb.carabiner.carabinerName, userManager: UserManager.shared) diff --git a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift index bb3f36f3c..a2d566335 100644 --- a/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift +++ b/Keychy/Keychy/Presentation/Home/Views/Main/HomeView.swift @@ -18,6 +18,9 @@ struct HomeView: View { @State var collectionViewModel: CollectionViewModel @State var bundleViewModel: BundleViewModel + /// 탭 선택 상태 (탭 전환 감지용) + @Binding var selectedTab: Int + /// 배경 로드 완료 콜백 var onBackgroundLoaded: (() -> Void)? = nil @@ -121,6 +124,14 @@ struct HomeView: View { } } } + .onChange(of: selectedTab) { _, _ in + // 탭 전환 시 뭉치 변경 팝업 닫기 + if showBundleSwitchPopup { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showBundleSwitchPopup = false + } + } + } .withToast(position: .tabbar) } } diff --git a/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift b/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift index 216db8714..1a6f3969b 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift @@ -31,8 +31,6 @@ struct CollectionTab: View { BundleDetailView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleCreateView: BundleCreateView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) - case .bundleAddKeyringView: - BundleAddKeyringView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleNameInputView: BundleNameInputView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleNameEditView: diff --git a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift index 4f938e6d8..0fd7d8a69 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift @@ -12,6 +12,7 @@ struct HomeTab: View { @Bindable var userManager: UserManager @Bindable var bundleViewModel: BundleViewModel @Bindable var collectionViewModel: CollectionViewModel + @Binding var selectedTab: Int @Bindable private var introViewModel = IntroViewModel() @State private var festivalViewModel = Showcase25BoardViewModel() @@ -23,7 +24,9 @@ struct HomeTab: View { HomeView( router: router, userManager: userManager, - collectionViewModel: collectionViewModel, bundleViewModel: bundleViewModel, + collectionViewModel: collectionViewModel, + bundleViewModel: bundleViewModel, + selectedTab: $selectedTab, onBackgroundLoaded: onBackgroundLoaded ) .navigationDestination(for: HomeRoute.self) {route in @@ -35,8 +38,6 @@ struct HomeTab: View { BundleDetailView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleCreateView: BundleCreateView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) - case .bundleAddKeyringView: - BundleAddKeyringView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleNameInputView: BundleNameInputView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleNameEditView: diff --git a/Keychy/Keychy/Presentation/Tab/Views/MainTabView.swift b/Keychy/Keychy/Presentation/Tab/Views/MainTabView.swift index 6c600070c..5539bcdb2 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/MainTabView.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/MainTabView.swift @@ -82,6 +82,7 @@ extension MainTabView { userManager: viewModel.userManager, bundleViewModel: viewModel.bundleViewModel, collectionViewModel: viewModel.collectionViewModel, + selectedTab: $viewModel.selectedTab, onBackgroundLoaded: { DispatchQueue.main.asyncAfter(deadline: .now() + MainTabViewModel.Delay.splashAnimation) { withAnimation(.easeOut(duration: 0.5)) { diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index c50b6fd17..02c89207b 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -222,8 +222,6 @@ struct WorkshopTab: View { BundleDetailView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleCreateView: BundleCreateView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) - case .bundleAddKeyringView: - BundleAddKeyringView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleNameInputView: BundleNameInputView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleNameEditView: diff --git a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray600.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray400.imageset/Contents.json similarity index 71% rename from Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray600.imageset/Contents.json rename to Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray400.imageset/Contents.json index c844a7ff3..627479718 100644 --- a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray600.imageset/Contents.json +++ b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray400.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "backgroundIconGray600.pdf", + "filename" : "backgroundIconGray400.pdf", "idiom" : "universal" } ], diff --git a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray400.imageset/backgroundIconGray400.pdf b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray400.imageset/backgroundIconGray400.pdf new file mode 100644 index 000000000..c51f66d95 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray400.imageset/backgroundIconGray400.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray600.imageset/backgroundIconGray600.pdf b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray600.imageset/backgroundIconGray600.pdf deleted file mode 100644 index 402e18094..000000000 Binary files a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/backgroundIconGray600.imageset/backgroundIconGray600.pdf and /dev/null differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray600.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray400.imageset/Contents.json similarity index 72% rename from Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray600.imageset/Contents.json rename to Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray400.imageset/Contents.json index 70895cb03..7611880db 100644 --- a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray600.imageset/Contents.json +++ b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray400.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "carabinerIconGray600.pdf", + "filename" : "carabinerIconGray400.pdf", "idiom" : "universal" } ], diff --git a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray600.imageset/carabinerIconGray600.pdf b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray400.imageset/carabinerIconGray400.pdf similarity index 68% rename from Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray600.imageset/carabinerIconGray600.pdf rename to Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray400.imageset/carabinerIconGray400.pdf index 539ae9c23..ea1ed4d5d 100644 Binary files a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray600.imageset/carabinerIconGray600.pdf and b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Features/carabinerIconGray400.imageset/carabinerIconGray400.pdf differ