diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index f7942fc2c..48f5546cb 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -357,7 +357,6 @@ AA2146B12F15D43C0048D40E /* BundleEditView+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146B02F15D43C0048D40E /* BundleEditView+Alert.swift */; }; AA2146B52F15D8490048D40E /* KeyringCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146B42F15D8490048D40E /* KeyringCell.swift */; }; AA2146B72F15E5B60048D40E /* BundleEditView+SelectSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146B62F15E5B60048D40E /* BundleEditView+SelectSheet.swift */; }; - AA2146B92F160E2B0048D40E /* BundleEditView+RestoreSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146B82F160E2B0048D40E /* BundleEditView+RestoreSelection.swift */; }; AA2146BB2F161D0C0048D40E /* BundleEditView+Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146BA2F161D0C0048D40E /* BundleEditView+Initialization.swift */; }; AA3908F82EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3908F72EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift */; }; AA3909462EC9F29500D87EEC /* UIApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */; }; @@ -384,6 +383,16 @@ AABA4DBD2ED2D6CA00A7D062 /* festivalCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABA4DBC2ED2D6CA00A7D062 /* festivalCard.swift */; }; AAEB46AD2EC1C893002B13E5 /* BundleMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAEB46AC2EC1C893002B13E5 /* BundleMenu.swift */; }; AAEB46AF2EC1D648002B13E5 /* BundleNameEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAEB46AE2EC1D648002B13E5 /* BundleNameEditView.swift */; }; + BC0001072F35F00100000007 /* BundleCreateView+SelectSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0001012F35F00100000001 /* BundleCreateView+SelectSheet.swift */; }; + BC0001082F35F00100000008 /* BundleCreateView+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0001022F35F00100000002 /* BundleCreateView+Alert.swift */; }; + BC0001092F35F00100000009 /* BundleCreateView+Purchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0001032F35F00100000003 /* BundleCreateView+Purchase.swift */; }; + BC00010A2F35F0010000000A /* BundleCreateView+Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0001042F35F00100000004 /* BundleCreateView+Initialization.swift */; }; + BC00010B2F35F0010000000B /* BundleCreateView+Capture.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0001052F35F00100000005 /* BundleCreateView+Capture.swift */; }; + BC00010C2F35F0010000000C /* BundleEditView+Capture.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0001062F35F00100000006 /* BundleEditView+Capture.swift */; }; + BC00020F2F35F00200000003 /* BundleSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC00020D2F35F00200000001 /* BundleSearchBar.swift */; }; + BC0002102F35F00200000004 /* KeyringEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC00020E2F35F00200000002 /* KeyringEmptyStateView.swift */; }; + BC0002132F35F00200000006 /* KeyringSelectionContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0002112F35F00200000005 /* KeyringSelectionContent.swift */; }; + BC0002152F35F00200000008 /* BundleKeyringCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0002142F35F00200000007 /* BundleKeyringCellView.swift */; }; C645AE9F2EB1055C004BFE69 /* CategoryTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C645AE9E2EB1055C004BFE69 /* CategoryTabBar.swift */; }; C645AEA32EB1B8FC004BFE69 /* DataInitializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C645AEA22EB1B8FC004BFE69 /* DataInitializer.swift */; }; C665DDE82EAEFA8700CE4495 /* CoinChargeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C665DDE72EAEFA8700CE4495 /* CoinChargeView.swift */; }; @@ -808,7 +817,6 @@ AA2146B02F15D43C0048D40E /* BundleEditView+Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+Alert.swift"; sourceTree = ""; }; AA2146B42F15D8490048D40E /* KeyringCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringCell.swift; sourceTree = ""; }; AA2146B62F15E5B60048D40E /* BundleEditView+SelectSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+SelectSheet.swift"; sourceTree = ""; }; - AA2146B82F160E2B0048D40E /* BundleEditView+RestoreSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+RestoreSelection.swift"; sourceTree = ""; }; AA2146BA2F161D0C0048D40E /* BundleEditView+Initialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+Initialization.swift"; sourceTree = ""; }; AA3908F72EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleDetailView+SaveImage.swift"; sourceTree = ""; }; AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Extension.swift"; sourceTree = ""; }; @@ -835,6 +843,16 @@ AABA4DBC2ED2D6CA00A7D062 /* festivalCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = festivalCard.swift; sourceTree = ""; }; AAEB46AC2EC1C893002B13E5 /* BundleMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleMenu.swift; sourceTree = ""; }; AAEB46AE2EC1D648002B13E5 /* BundleNameEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleNameEditView.swift; sourceTree = ""; }; + BC0001012F35F00100000001 /* BundleCreateView+SelectSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCreateView+SelectSheet.swift"; sourceTree = ""; }; + BC0001022F35F00100000002 /* BundleCreateView+Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCreateView+Alert.swift"; sourceTree = ""; }; + BC0001032F35F00100000003 /* BundleCreateView+Purchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCreateView+Purchase.swift"; sourceTree = ""; }; + BC0001042F35F00100000004 /* BundleCreateView+Initialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCreateView+Initialization.swift"; sourceTree = ""; }; + BC0001052F35F00100000005 /* BundleCreateView+Capture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCreateView+Capture.swift"; sourceTree = ""; }; + BC0001062F35F00100000006 /* BundleEditView+Capture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+Capture.swift"; sourceTree = ""; }; + BC00020D2F35F00200000001 /* BundleSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleSearchBar.swift; sourceTree = ""; }; + BC00020E2F35F00200000002 /* KeyringEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringEmptyStateView.swift; sourceTree = ""; }; + BC0002112F35F00200000005 /* KeyringSelectionContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringSelectionContent.swift; sourceTree = ""; }; + BC0002142F35F00200000007 /* BundleKeyringCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleKeyringCellView.swift; sourceTree = ""; }; C645AE9E2EB1055C004BFE69 /* CategoryTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryTabBar.swift; sourceTree = ""; }; C645AEA22EB1B8FC004BFE69 /* DataInitializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataInitializer.swift; sourceTree = ""; }; C665DDE72EAEFA8700CE4495 /* CoinChargeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinChargeView.swift; sourceTree = ""; }; @@ -1792,6 +1810,7 @@ 4CEC61F62EAE08DA0099ECEE /* BackgroundCell.swift */, AA9115072EB126A60026E9BC /* CarabinerCell.swift */, AA2146B42F15D8490048D40E /* KeyringCell.swift */, + BC0002142F35F00200000007 /* BundleKeyringCellView.swift */, AAA4467B2EC64C9900080AB1 /* SelectBackgroundSheet.swift */, AAA446812EC6519700080AB1 /* SelectCarabinerSheet.swift */, 4C2525D92F35B2A7003CC5AD /* BundleSheetFilterBar.swift */, @@ -1800,6 +1819,9 @@ F82FD6112F9442AAAD58DB97 /* BundleSheetToggleButtons.swift */, 5E036016318043AC801A19DA /* BundlePurchaseCartItem.swift */, AA6298552EC3AD16001576C0 /* DraggableSheet.swift */, + BC00020D2F35F00200000001 /* BundleSearchBar.swift */, + BC00020E2F35F00200000002 /* KeyringEmptyStateView.swift */, + BC0002112F35F00200000005 /* KeyringSelectionContent.swift */, ); path = Shared; sourceTree = ""; @@ -2117,6 +2139,11 @@ isa = PBXGroup; children = ( AA6298532EC39065001576C0 /* BundleCreateView.swift */, + BC0001042F35F00100000004 /* BundleCreateView+Initialization.swift */, + BC0001052F35F00100000005 /* BundleCreateView+Capture.swift */, + BC0001022F35F00100000002 /* BundleCreateView+Alert.swift */, + BC0001012F35F00100000001 /* BundleCreateView+SelectSheet.swift */, + BC0001032F35F00100000003 /* BundleCreateView+Purchase.swift */, AA0219DD2EB1C041006EF269 /* BundleNameInputView.swift */, ); path = Create; @@ -2184,7 +2211,7 @@ children = ( AA6298512EC233D2001576C0 /* BundleEditView.swift */, AA2146BA2F161D0C0048D40E /* BundleEditView+Initialization.swift */, - AA2146B82F160E2B0048D40E /* BundleEditView+RestoreSelection.swift */, + BC0001062F35F00100000006 /* BundleEditView+Capture.swift */, AA2146B02F15D43C0048D40E /* BundleEditView+Alert.swift */, AA2146B62F15E5B60048D40E /* BundleEditView+SelectSheet.swift */, AA2146AE2F15D0160048D40E /* BundleEditView+Purchase.swift */, @@ -2740,6 +2767,10 @@ AA0A54B92EC05C41007B5413 /* BundleRingComponent.swift in Sources */, C6C361422ED44EE4009642F4 /* Showcase25BoardView+Sheet.swift in Sources */, 4C2525DA2F35B2A7003CC5AD /* BundleSheetFilterBar.swift in Sources */, + BC00020F2F35F00200000003 /* BundleSearchBar.swift in Sources */, + BC0002102F35F00200000004 /* KeyringEmptyStateView.swift in Sources */, + BC0002132F35F00200000006 /* KeyringSelectionContent.swift in Sources */, + BC0002152F35F00200000008 /* BundleKeyringCellView.swift in Sources */, 3828F5452EC4CC0A00F1B040 /* CollectionViewModel+Filter.swift in Sources */, 38C3C28A2EC1D879003C5DE1 /* UnpackPopup.swift in Sources */, C6C361E42ED4AF48009642F4 /* Showcase25BoardView+Grid.swift in Sources */, @@ -2799,7 +2830,12 @@ 4CEC61EC2EAE08C00099ECEE /* BodyType.swift in Sources */, C6C361E22ED4AC49009642F4 /* SubmitCompleteAlert.swift in Sources */, C6C361442ED44EF1009642F4 /* Showcase25BoardView+Detail.swift in Sources */, - AA2146B92F160E2B0048D40E /* BundleEditView+RestoreSelection.swift in Sources */, + BC0001072F35F00100000007 /* BundleCreateView+SelectSheet.swift in Sources */, + BC0001082F35F00100000008 /* BundleCreateView+Alert.swift in Sources */, + BC0001092F35F00100000009 /* BundleCreateView+Purchase.swift in Sources */, + BC00010A2F35F0010000000A /* BundleCreateView+Initialization.swift in Sources */, + BC00010B2F35F0010000000B /* BundleCreateView+Capture.swift in Sources */, + BC00010C2F35F0010000000C /* BundleEditView+Capture.swift in Sources */, 38C3C2922EC1F787003C5DE1 /* CollectionKeyringDetailView+Lifecycle.swift in Sources */, 4C8426642ED375840050B6FE /* ColorPalette.swift in Sources */, C6C402A32EB40ACA006B58DF /* AlarmView.swift in Sources */, diff --git a/Keychy/Keychy/Core/Components/View/Button/ToolbarButtons.swift b/Keychy/Keychy/Core/Components/View/Button/ToolbarButtons.swift index 1b6a3c251..4dd85612f 100644 --- a/Keychy/Keychy/Core/Components/View/Button/ToolbarButtons.swift +++ b/Keychy/Keychy/Core/Components/View/Button/ToolbarButtons.swift @@ -136,6 +136,7 @@ struct PurchaseToolbarButton: View { // MARK: - Custom Text Toolbar Button struct TextToolbarButton: View { let title: String + var isDisabled: Bool = false let action: () -> Void var body: some View { @@ -143,9 +144,10 @@ struct TextToolbarButton: View { Text(title) .typography(.suit17B) .padding(4) - .foregroundStyle(.black100) + .foregroundStyle(isDisabled ? .gray300 : .main500) } .frame(width: 62, height: 44) .glassEffect(.regular.interactive(), in: .capsule) + .disabled(isDisabled) } } diff --git a/Keychy/Keychy/Core/DesignSystem/Colors/Gradient+Keychy.swift b/Keychy/Keychy/Core/DesignSystem/Colors/Gradient+Keychy.swift index cf7f8189c..f77188c13 100644 --- a/Keychy/Keychy/Core/DesignSystem/Colors/Gradient+Keychy.swift +++ b/Keychy/Keychy/Core/DesignSystem/Colors/Gradient+Keychy.swift @@ -13,6 +13,7 @@ import SwiftUI enum GradientStyle { case primary + case festivalPublished } extension GradientStyle { @@ -24,6 +25,12 @@ extension GradientStyle { startPoint: .topLeading, endPoint: .bottomTrailing ) + case .festivalPublished: + return LinearGradient( + colors: [.gradient3, .gradient4], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) } } } diff --git a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift index 21023dc00..001ae59c3 100644 --- a/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift +++ b/Keychy/Keychy/Core/DesignSystem/Typography/Typography.swift @@ -99,6 +99,7 @@ struct Typography { static let notosans12R = Typography(font: .custom(.notoSansRegular, size: 12), lineSpacing: 0) static let notosans13M = Typography(font: .custom(.notoSansMedium, size: 13), lineSpacing: 0) + static let notosans13SB = Typography(font: .custom(.notoSansSemiBold, size: 13), lineSpacing: 0) static let notosans14SB = Typography(font: .custom(.notoSansSemiBold, size: 14), lineSpacing: 0) static let notosans14M = Typography(font: .custom(.notoSansMedium, size: 14), lineSpacing: 0) diff --git a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Edit.swift b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Edit.swift index 96d3d4427..2f6c0f1dc 100644 --- a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Edit.swift +++ b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Edit.swift @@ -192,41 +192,39 @@ extension BundleViewModel { /// 키링 선택 시트용 정렬된 키링 리스트 /// - 1순위: 현재 위치에 선택된 키링 - /// - 2순위: 일반 키링들 (선택되지 않고, published/packaged 아님) - /// - 3순위: 다른 위치에 장착된 키링들 + /// - 1순위: 현재 위치에 장착된 키링 + /// - 2순위: 다른 위치에 장착된 키링들 + /// - 3순위: 일반 키링들 (선택되지 않고, published/packaged 아님) /// - 4순위: published 또는 packaged 상태의 키링들 (맨 뒤) func sortedKeyringsForSelection(selectedKeyrings: [Int: Keyring], selectedPosition: Int) -> [Keyring] { - let selectedKeyring = selectedKeyrings[selectedPosition] + let currentKeyring = selectedKeyrings[selectedPosition] return keyring.sorted { keyring1, keyring2 in - let isKeyring1SelectedHere = keyring1.id == selectedKeyring?.id - let isKeyring2SelectedHere = keyring2.id == selectedKeyring?.id + let isKeyring1Current = keyring1.id == currentKeyring?.id + let isKeyring2Current = keyring2.id == currentKeyring?.id - let isKeyring1SelectedElsewhere = selectedKeyrings.values.contains { $0.id == keyring1.id } && !isKeyring1SelectedHere - let isKeyring2SelectedElsewhere = selectedKeyrings.values.contains { $0.id == keyring2.id } && !isKeyring2SelectedHere + let isKeyring1Elsewhere = selectedKeyrings.values.contains { $0.id == keyring1.id } && !isKeyring1Current + let isKeyring2Elsewhere = selectedKeyrings.values.contains { $0.id == keyring2.id } && !isKeyring2Current let isKeyring1Unavailable = keyring1.status == .published || keyring1.status == .packaged let isKeyring2Unavailable = keyring2.status == .published || keyring2.status == .packaged - if isKeyring1SelectedHere != isKeyring2SelectedHere { - return isKeyring1SelectedHere + // 1순위: 현재 위치 키링 + if isKeyring1Current != isKeyring2Current { + return isKeyring1Current } - let isKeyring1Normal = !isKeyring1SelectedElsewhere && !isKeyring1Unavailable - let isKeyring2Normal = !isKeyring2SelectedElsewhere && !isKeyring2Unavailable - - if isKeyring1Normal != isKeyring2Normal { - return isKeyring1Normal - } - - if isKeyring1SelectedElsewhere != isKeyring2SelectedElsewhere { - return isKeyring1SelectedElsewhere + // 2순위: 다른 위치 장착 키링 + if isKeyring1Elsewhere != isKeyring2Elsewhere { + return isKeyring1Elsewhere } + // 3순위: 일반 키링 (사용 불가 아닌 것) if isKeyring1Unavailable != isKeyring2Unavailable { return isKeyring2Unavailable } + // 동일 순위면 원래 순서 유지 guard let index1 = keyring.firstIndex(of: keyring1), let index2 = keyring.firstIndex(of: keyring2) else { return false diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Alert.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Alert.swift new file mode 100644 index 000000000..eb1283d72 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Alert.swift @@ -0,0 +1,55 @@ +// +// BundleCreateView+Alert.swift +// Keychy +// +// Created by 김서현 on 11/12/25. +// + +import SwiftUI + +// MARK: - Alert 컨텐츠 +extension BundleCreateView { + var alertContent: some View { + ZStack { + // 구매 성공 Alert + if showPurchaseSuccessAlert { + Color.black20 + .ignoresSafeArea() + .onTapGesture { + showPurchaseSuccessAlert = false + purchasesSuccessScale = 0.3 + } + + KeychyAlert(type: .checkmark, message: "구매가 완료되었어요!", isPresented: $showPurchaseSuccessAlert) + .zIndex(101) + } + + // 구매 실패 Alert + if showPurchaseFailAlert { + ZStack { + Color.black20 + .ignoresSafeArea() + .onTapGesture { + showPurchaseFailAlert = false + purchaseFailScale = 0.3 + } + + PurchaseFailAlert( + checkmarkScale: purchaseFailScale, + onCancel: { + showPurchaseFailAlert = false + purchaseFailScale = 0.3 + }, + onCharge: { + showPurchaseFailAlert = false + purchaseFailScale = 0.3 + bundleVM.saveCurrentSelection() + router.push(.coinCharge) + } + ) + .padding(.horizontal, 51) + } + } + } + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift new file mode 100644 index 000000000..076083089 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Capture.swift @@ -0,0 +1,128 @@ +// +// BundleCreateView+Capture.swift +// Keychy +// +// Created by 김서현 on 11/12/25. +// + +import SwiftUI + +// MARK: - 키링 데이터 및 캡처 +extension BundleCreateView { + /// 키링 데이터 리스트 생성 (씬 표시용) + 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 + } + + /// 씬 캡처 및 저장 + 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/Create/BundleCreateView+Initialization.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Initialization.swift new file mode 100644 index 000000000..125060a3a --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Initialization.swift @@ -0,0 +1,127 @@ +// +// BundleCreateView+Initialization.swift +// Keychy +// +// Created by 김서현 on 11/12/25. +// + +import SwiftUI + +// MARK: - 데이터 초기화 +extension BundleCreateView { + + /// 초기 데이터 로딩 + func initializeData() async { + // 사용자가 소유한 배경과 카라비너 데이터를 가져옴 + await loadUserOwnedItems() + } + + /// 화면이 다시 나타날 때 데이터 새로고침 + func refreshData() async { + guard let _ = UserManager.shared.currentUser else { + return + } + + // 현재 선택된 아이템의 ID 저장 + let currentBackgroundId = bundleVM.newSelectedBackground?.background.id + let currentCarabinerId = bundleVM.newSelectedCarabiner?.carabiner.id + + // 배경 데이터 새로고침 + await withCheckedContinuation { continuation in + bundleVM.fetchAllBackgrounds { _ in + // 이전에 선택했던 배경을 다시 찾아서 선택 (구매 상태가 업데이트됨) + if let bgId = currentBackgroundId { + self.bundleVM.newSelectedBackground = bundleVM.backgroundViewData.first { $0.background.id == bgId } + } + continuation.resume() + } + } + + // 카라비너 데이터 새로고침 + await withCheckedContinuation { continuation in + bundleVM.fetchAllCarabiners { _ in + // 이전에 선택했던 카라비너를 다시 찾아서 선택 (구매 상태가 업데이트됨) + if let cbId = currentCarabinerId { + self.bundleVM.newSelectedCarabiner = bundleVM.carabinerViewData.first { $0.carabiner.id == cbId } + } + continuation.resume() + } + } + } + + /// 사용자가 소유한 배경과 카라비너 아이템들을 로드 + func loadUserOwnedItems() async { + guard let _ = UserManager.shared.currentUser else { + return + } + + let uid = UserManager.shared.userUID + + // 배경 데이터 로드 + await withCheckedContinuation { continuation in + bundleVM.fetchAllBackgrounds { _ in + continuation.resume() + } + } + + // 카라비너 데이터 로드 + await withCheckedContinuation { continuation in + bundleVM.fetchAllCarabiners { _ in + 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 + } + continuation.resume() + } + } else { + continuation.resume() + } + } + } + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Purchase.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Purchase.swift new file mode 100644 index 000000000..d4e73f47e --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+Purchase.swift @@ -0,0 +1,156 @@ +// +// BundleCreateView+Purchase.swift +// Keychy +// +// Created by 김서현 on 11/12/25. +// + +import SwiftUI + +// MARK: - 구매 시트 뷰 +extension BundleCreateView { + var purchaseSheetView: some View { + VStack(spacing: 12) { + // 상단 섹션 - 닫기 버튼, 타이틀 + HStack { + Button { + showPurchaseSheet = false + } label: { + Image(systemName: "xmark") + .font(.system(size: 20)) + .foregroundStyle(.gray600) + } + Spacer() + Text("구매하기") + .typography(.suit17B) + .foregroundStyle(.gray600) + Spacer() + } + .padding(EdgeInsets(top: 30, leading: 20, bottom: 10, trailing: 20)) + + // 구매할 아이템 목록 + VStack(spacing: 20) { + if let bg = bundleVM.newSelectedBackground, !bg.isOwned && bg.background.price > 0 { + 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( + imageURL: cb.carabiner.carabinerImage.first ?? "", + name: cb.carabiner.carabinerName, + type: "카라비너", + price: cb.carabiner.price + ) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 30) + + // 내 보유 재화와 총 가격 + HStack(spacing: 6) { + Text("내 보유 : ") + .typography(.suit15M25) + .foregroundStyle(.black100) + .padding(.vertical, 4.5) + Text("\(UserManager.shared.currentUser?.coin ?? 0)") + .typography(.nanum16EB) + .foregroundStyle(.main500) + } + purchaseButton + .padding(.horizontal, 33.2) + .padding(.bottom, 40) + .adaptiveBottomPadding() + } + .background( + UnevenRoundedRectangle(topLeadingRadius: 38, topTrailingRadius: 38) + .fill(.white100) + ) + } + + // 구매 버튼 + var purchaseButton: some View { + Button { + Task { + await purchaseItems() + } + } label: { + HStack(spacing: 5) { + if bundleVM.isPurchasing { + LoadingAlert(type: .short40, message: nil) + } else { + Image(.myCoinMini) + } + + Text("\(bundleVM.totalCartPrice)") + .typography(.nanum18EB) + .padding(.top, 16) + .padding(.bottom, 12) + + Text("(\(bundleVM.payableItemsCount)개)") + .typography(.suit17SB) + } + .foregroundStyle(.white100) + .frame(maxWidth: .infinity) + .background(bundleVM.isPurchasing ? .gray400 : .black80) + .clipShape(RoundedRectangle(cornerRadius: 100)) + } + .disabled(bundleVM.isPurchasing) + } + + // MARK: - 구매 처리 + func purchaseItems() async { + let result = await bundleVM.purchaseSelectedItems() + + switch result { + case .success: + // 모든 구매 성공 + await refreshData() + + await MainActor.run { + // ViewModel 상태 동기화 + if let bg = bundleVM.newSelectedBackground { + bundleVM.selectedBackground = bg.background + } + if let cb = bundleVM.newSelectedCarabiner { + bundleVM.selectedCarabiner = cb.carabiner + } + + showPurchaseSheet = false + showPurchaseSuccessAlert = true + purchasesSuccessScale = 0.3 + withAnimation(.spring(response: 0.6, dampingFraction: 0.5)) { + purchasesSuccessScale = 1.0 + } + } + + // 2.5초 후 알럿 자동 닫기 (Alert duration 2초 + 0.5초 여유) + try? await Task.sleep(for: .seconds(2.5)) + + await MainActor.run { + showPurchaseSuccessAlert = false + purchasesSuccessScale = 0.3 + } + + case .insufficientCoins, .failed: + // 구매 실패 + await MainActor.run { + showPurchaseSheet = false + } + + // 시트 닫히는 애니메이션 대기 + try? await Task.sleep(for: .seconds(0.3)) + + await MainActor.run { + showPurchaseFailAlert = true + purchaseFailScale = 0.3 + withAnimation(.spring(response: 0.6, dampingFraction: 0.5)) { + purchaseFailScale = 1.0 + } + } + } + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+SelectSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+SelectSheet.swift new file mode 100644 index 000000000..340060f8b --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleCreateView+SelectSheet.swift @@ -0,0 +1,155 @@ +// +// BundleCreateView+SelectSheet.swift +// Keychy +// +// Created by 김서현 on 11/12/25. +// + +import SwiftUI + +// MARK: - 키링 버튼 +extension BundleCreateView { + 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..: View { - - //MARK: - 프로퍼티들 + + // MARK: - 프로퍼티들 @Bindable var router: NavigationRouter @State var collectionVM: CollectionViewModel @Bindable var bundleVM: BundleViewModel // 시트 활성화 상태 - @State private var showItemSheet: Bool = false - @State private var isBackgroundMode: Bool = true // true: 배경, false: 카라비너 - @State private var showKeyringSheet: Bool = false + @State var showItemSheet: Bool = false + @State var isBackgroundMode: Bool = true // true: 배경, false: 카라비너 + @State var showKeyringSheet: Bool = false // 시트 높이 - @State private var sheetHeight: CGFloat = 360 + @State var sheetHeight: CGFloat = 360 // 키링 선택 상태 - @State private var selectedKeyrings: [Int: Keyring] = [:] - @State private var keyringOrder: [Int] = [] - @State private var selectedPosition: Int = 0 + @State var selectedKeyrings: [Int: Keyring] = [:] + @State var keyringOrder: [Int] = [] + @State var selectedPosition: Int = 0 + @State var keyringSearchText: String = "" // 캡처 상태 - @State private var isCapturing: Bool = false - @State private var sceneRefreshId = UUID() - @State private var isSceneReady: Bool = false + @State var isCapturing: Bool = false + @State var sceneRefreshId = UUID() + @State var isSceneReady: Bool = false // 구매 시트 @State var showPurchaseSheet = false @@ -45,15 +52,15 @@ struct BundleCreateView: View { @State var purchaseFailScale: CGFloat = 0.3 // 공통 그리드 컬럼 (배경, 카라비너, 키링 모두 동일) - private let gridColumns: [GridItem] = [ + let gridColumns: [GridItem] = [ GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10) ] - private let sheetHeightRatio: CGFloat = 0.5 - - //MARK: 메인 뷰 + let sheetHeightRatio: CGFloat = 0.5 + + // MARK: - 메인 뷰 var body: some View { ZStack(alignment: .bottom) { if let bg = bundleVM.newSelectedBackground, @@ -163,7 +170,7 @@ struct BundleCreateView: View { // MARK: - 커스텀 네비게이션 바 extension BundleCreateView { - private var customNavigationBar: some View { + var customNavigationBar: some View { CustomNavigationBar { BackToolbarButton { router.pop() @@ -175,637 +182,20 @@ extension BundleCreateView { showPurchaseSheet = true } } else { - NextToolbarButton(isDisabled: isCapturing || selectedKeyrings.isEmpty) { + Button { Task { await captureAndSaveScene() } - } - } - } - } -} - - -// 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 { - 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) - } -} - - - -// MARK: - 데이터 가져오는 메서드 -extension BundleCreateView { - - /// 초기 데이터 로딩 - private func initializeData() async { - // 사용자가 소유한 배경과 카라비너 데이터를 가져옴 - await loadUserOwnedItems() - } - - /// 화면이 다시 나타날 때 데이터 새로고침 - private func refreshData() async { - guard let _ = UserManager.shared.currentUser else { - return - } - - // 현재 선택된 아이템의 ID 저장 - let currentBackgroundId = bundleVM.newSelectedBackground?.background.id - let currentCarabinerId = bundleVM.newSelectedCarabiner?.carabiner.id - - // 배경 데이터 새로고침 - await withCheckedContinuation { continuation in - bundleVM.fetchAllBackgrounds { _ in - // 이전에 선택했던 배경을 다시 찾아서 선택 (구매 상태가 업데이트됨) - if let bgId = currentBackgroundId { - self.bundleVM.newSelectedBackground = bundleVM.backgroundViewData.first { $0.background.id == bgId } - } - continuation.resume() - } - } - - // 카라비너 데이터 새로고침 - await withCheckedContinuation { continuation in - bundleVM.fetchAllCarabiners { _ in - // 이전에 선택했던 카라비너를 다시 찾아서 선택 (구매 상태가 업데이트됨) - if let cbId = currentCarabinerId { - self.bundleVM.newSelectedCarabiner = bundleVM.carabinerViewData.first { $0.carabiner.id == cbId } - } - continuation.resume() - } - } - } - - /// 사용자가 소유한 배경과 카라비너 아이템들을 로드 - private func loadUserOwnedItems() async { - guard let _ = UserManager.shared.currentUser else { - return - } - - let uid = UserManager.shared.userUID - - // 배경 데이터 로드 - await withCheckedContinuation { continuation in - bundleVM.fetchAllBackgrounds { _ in - continuation.resume() - } - } - - // 카라비너 데이터 로드 - await withCheckedContinuation { continuation in - bundleVM.fetchAllCarabiners { _ in - 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 - } - continuation.resume() - } - } else { - continuation.resume() - } - } - } - } -} - -// MARK: - Alert 컨텐츠 -extension BundleCreateView { - private var alertContent: some View { - ZStack { - // 구매 성공 Alert - if showPurchaseSuccessAlert { - Color.black20 - .ignoresSafeArea() - .onTapGesture { - showPurchaseSuccessAlert = false - purchasesSuccessScale = 0.3 - } - - KeychyAlert(type: .checkmark, message: "구매가 완료되었어요!", isPresented: $showPurchaseSuccessAlert) - .zIndex(101) - } - - // 구매 실패 Alert - if showPurchaseFailAlert { - ZStack { - Color.black20 - .ignoresSafeArea() - .onTapGesture { - showPurchaseFailAlert = false - purchaseFailScale = 0.3 - } - - PurchaseFailAlert( - checkmarkScale: purchaseFailScale, - onCancel: { - showPurchaseFailAlert = false - purchaseFailScale = 0.3 - }, - onCharge: { - showPurchaseFailAlert = false - purchaseFailScale = 0.3 - bundleVM.saveCurrentSelection() - router.push(.coinCharge) - } - ) - .padding(.horizontal, 51) - } - } - } - } -} - -// MARK: - 구매 시트 뷰 -extension BundleCreateView { - private var purchaseSheetView: some View { - VStack(spacing: 12) { - // 상단 섹션 - 닫기 버튼, 타이틀 - HStack { - Button { - showPurchaseSheet = false } label: { - Image(systemName: "xmark") - .font(.system(size: 20)) - .foregroundStyle(.gray600) - } - Spacer() - Text("구매하기") - .typography(.suit17B) - .foregroundStyle(.gray600) - Spacer() - } - .padding(EdgeInsets(top: 30, leading: 20, bottom: 10, trailing: 20)) - - // 구매할 아이템 목록 - VStack(spacing: 20) { - if let bg = bundleVM.newSelectedBackground, !bg.isOwned && bg.background.price > 0 { - 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( - imageURL: cb.carabiner.carabinerImage.first ?? "", - name: cb.carabiner.carabinerName, - type: "카라비너", - price: cb.carabiner.price - ) - } - } - .padding(.horizontal, 20) - .padding(.bottom, 30) - - // 내 보유 재화와 총 가격 - HStack(spacing: 6) { - Text("내 보유 : ") - .typography(.suit15M25) - .foregroundStyle(.black100) - .padding(.vertical, 4.5) - Text("\(UserManager.shared.currentUser?.coin ?? 0)") - .typography(.nanum16EB) - .foregroundStyle(.main500) - } - purchaseButton - .padding(.horizontal, 33.2) - .padding(.bottom, 40) - .adaptiveBottomPadding() - } - .background( - UnevenRoundedRectangle(topLeadingRadius: 38, topTrailingRadius: 38) - .fill(.white100) - ) - } - - // 구매 버튼 - private var purchaseButton: some View { - Button { - Task { - await purchaseItems() - } - } label: { - HStack(spacing: 5) { - if bundleVM.isPurchasing { - LoadingAlert(type: .short40, message: nil) - } else { - Image(.myCoinMini) + Text("다음") + .typography(.suit17B) + .padding(4) + .foregroundStyle(isCapturing || selectedKeyrings.isEmpty ? .gray300 : .main500) } - - Text("\(bundleVM.totalCartPrice)") - .typography(.nanum18EB) - .padding(.top, 16) - .padding(.bottom, 12) - - Text("(\(bundleVM.payableItemsCount)개)") - .typography(.suit17SB) + .frame(width: 62, height: 44) + .glassEffect(.regular.interactive(), in: .capsule) + .disabled(isCapturing || selectedKeyrings.isEmpty) } - .foregroundStyle(.white100) - .frame(maxWidth: .infinity) - .background(bundleVM.isPurchasing ? .gray400 : .black80) - .clipShape(RoundedRectangle(cornerRadius: 100)) - } - .disabled(bundleVM.isPurchasing) - } - - // MARK: - 구매 처리 - private func purchaseItems() async { - let result = await bundleVM.purchaseSelectedItems() - - switch result { - case .success: - // 모든 구매 성공 - await refreshData() - - await MainActor.run { - // ViewModel 상태 동기화 - if let bg = bundleVM.newSelectedBackground { - bundleVM.selectedBackground = bg.background - } - if let cb = bundleVM.newSelectedCarabiner { - bundleVM.selectedCarabiner = cb.carabiner - } - - showPurchaseSheet = false - showPurchaseSuccessAlert = true - purchasesSuccessScale = 0.3 - withAnimation(.spring(response: 0.6, dampingFraction: 0.5)) { - purchasesSuccessScale = 1.0 - } - } - - // 2.5초 후 알럿 자동 닫기 (Alert duration 2초 + 0.5초 여유) - try? await Task.sleep(for: .seconds(2.5)) - - await MainActor.run { - showPurchaseSuccessAlert = false - purchasesSuccessScale = 0.3 - } - - case .insufficientCoins, .failed: - // 구매 실패 - await MainActor.run { - showPurchaseSheet = false - } - - // 시트 닫히는 애니메이션 대기 - try? await Task.sleep(for: .seconds(0.3)) - - await MainActor.run { - showPurchaseFailAlert = true - purchaseFailScale = 0.3 - withAnimation(.spring(response: 0.6, dampingFraction: 0.5)) { - purchaseFailScale = 1.0 - } - } - } - } -} - -// 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/Create/BundleNameInputView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleNameInputView.swift index 4a2008c59..33080d622 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleNameInputView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleNameInputView.swift @@ -160,14 +160,14 @@ extension BundleNameInputView { } center: { EmptyView() } trailing: { - TextToolbarButton(title: "완료") { + TextToolbarButton( + title: "완료", + isDisabled: isUploading || + bundleName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + hasProfanity + ) { handleNextButtonTap() } - .disabled( - isUploading || - bundleName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - hasProfanity - ) } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift index e177c5d22..c40e45293 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Alert.swift @@ -96,7 +96,7 @@ extension BundleEditView { onCharge: { showPurchaseFailAlert = false purchaseFailScale = 0.3 - saveCurrentSelection() + bundleVM.saveCurrentSelection() router.push(.coinCharge) } ) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift new file mode 100644 index 000000000..ed88e1652 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Capture.swift @@ -0,0 +1,116 @@ +// +// BundleEditView+Capture.swift +// Keychy +// +// Created by 김서현 on 11/10/25. +// + +import SwiftUI + +// MARK: - 키링 데이터 및 캡처 +extension BundleEditView { + + /// 키링 데이터 리스트 업데이트 + func updateKeyringDataList() { + guard let carabiner = bundleVM.newSelectedCarabiner?.carabiner else { + keyringDataList = [] + return + } + + let newData = bundleVM.createKeyringDataListFromSelected( + selectedKeyrings: bundleVM.selectedKeyrings, + keyringOrder: bundleVM.keyringOrder, + carabiner: carabiner + ) + + // 데이터가 실제로 변경된 경우에만 업데이트 + if keyringDataList != newData { + keyringDataList = newData + + // 키링이 추가/변경될 때도 씬을 새로고침하여 확실히 반영되도록 함 + if !newData.isEmpty { + sceneRefreshId = UUID() + } + } + } + + // MARK: - 썸네일 재캡쳐 & 캐시 저장 + func recaptureAndCacheBundleThumbnail(bundleId: String, bundleName: String, createdAt: Date) async { + // 편집 중 상태로 캡쳐 + guard let bg = bundleVM.newSelectedBackground?.background, + let cb = bundleVM.newSelectedCarabiner?.carabiner else { + return + } + + await MainActor.run { + isCapturing = true + } + + // 캡쳐용 키링 데이터 생성 (편집 중 keyringDataList -> 캡쳐용으로 변환) + let captureKeyrings: [MultiKeyringCaptureScene.KeyringData] = keyringDataList.map { item in + MultiKeyringCaptureScene.KeyringData( + index: item.index, + position: item.position, + bodyImageURL: item.bodyImageURL, + hookOffsetY: item.hookOffsetY, + chainLength: item.chainLength + ) + } + + // 카라비너 타입 및 이미지 URL + let carabinerType = cb.type + let carabinerBackURL: String? + let carabinerFrontURL: String? + if carabinerType == .hamburger { + carabinerBackURL = cb.carabinerImage[1] + carabinerFrontURL = cb.carabinerImage[2] + } else { + carabinerBackURL = cb.carabinerImage[0] + carabinerFrontURL = nil + } + + // 1. 배경 포함 캡쳐 (앱용) + guard let fullImageData = await MultiKeyringCaptureScene.captureBundleImage( + keyringDataList: captureKeyrings, + backgroundImageURL: bg.backgroundImage, + carabinerBackImageURL: carabinerBackURL, + carabinerFrontImageURL: carabinerFrontURL, + carabinerType: carabinerType, + carabinerX: cb.carabinerX, + carabinerY: cb.carabinerY, + carabinerWidth: cb.carabinerWidth + ) else { + await MainActor.run { + isCapturing = false + } + return + } + + // 2. 배경 없이 캡쳐 (위젯용 - 투명 여백 제거 후 리사이즈) + let widgetImageData = await MultiKeyringCaptureScene.captureBundleImage( + keyringDataList: captureKeyrings, + backgroundImageURL: nil, + carabinerBackImageURL: carabinerBackURL, + carabinerFrontImageURL: carabinerFrontURL, + carabinerType: carabinerType, + carabinerX: cb.carabinerX, + carabinerY: cb.carabinerY, + carabinerWidth: cb.carabinerWidth, + trimTransparentEdges: true + ) + + // 캐시 저장 (full + widget) + BundleImageCache.shared.syncBundle( + id: bundleId, + name: bundleName, + fullImageData: fullImageData, + widgetImageData: widgetImageData, + createdAt: createdAt + ) + + await MainActor.run { + bundleVM.bundleCapturedImage = fullImageData + isCapturing = false + } + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Initialization.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Initialization.swift index 23e5c4c1f..d724f7028 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Initialization.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+Initialization.swift @@ -43,7 +43,6 @@ extension BundleEditView { bgData.background.id == selectedBundle.selectedBackground } } - self.restoreBackgroundSelection() bundleVM.fetchAllCarabiners { _ in // 현재 뭉치의 카라비너로 항상 초기화 @@ -52,7 +51,9 @@ extension BundleEditView { cbData.carabiner.id == selectedBundle.selectedCarabiner } } - self.restoreCarabinerSelection() + + // 코인 충전 후 복귀 시 저장된 선택 복원 + bundleVM.restoreSelectionIfNeeded() Task { // Firebase 데이터를 한 번만 로컬 상태로 초기화 diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+RestoreSelection.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+RestoreSelection.swift deleted file mode 100644 index 5b9b2fc30..000000000 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+RestoreSelection.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// BundleEditView+RestoreSelection.swift -// Keychy -// -// Created by 김서현 on 1/13/26. -// -// 뭉치 편집뷰에서 선택했던 항목을 복구하는 메서드를 모아둔 파일입니다. - -import SwiftUI - -extension BundleEditView { - // MARK: - 선택 상태 저장/복원 - func saveCurrentSelection() { - if let bg = bundleVM.newSelectedBackground { - UserDefaults.standard.set(bg.background.id, forKey: "tempSelectedBackgroundId") - } - if let cb = bundleVM.newSelectedCarabiner { - UserDefaults.standard.set(cb.carabiner.id, forKey: "tempSelectedCarabinerId") - } - } - - func restoreSelection() { - restoreBackgroundSelection() - restoreCarabinerSelection() - } - - func restoreBackgroundSelection() { - if let savedBackgroundId = UserDefaults.standard.string(forKey: "tempSelectedBackgroundId") { - if let restoredBackground = bundleVM.backgroundViewData.first(where: { $0.background.id == savedBackgroundId }) { - bundleVM.newSelectedBackground = restoredBackground - // 복원 후 삭제 - UserDefaults.standard.removeObject(forKey: "tempSelectedBackgroundId") - } - } - } - - func restoreCarabinerSelection() { - if let savedCarbinerId = UserDefaults.standard.string(forKey: "tempSelectedCarabinerId") { - if let restoredCarabiner = bundleVM.carabinerViewData.first(where: { $0.carabiner.id == savedCarbinerId }) { - bundleVM.newSelectedCarabiner = restoredCarabiner - // 복원 후 삭제 - UserDefaults.standard.removeObject(forKey: "tempSelectedCarabinerId") - } - } - } -} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+SelectSheet.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+SelectSheet.swift index 552032639..2105941e1 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+SelectSheet.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView+SelectSheet.swift @@ -66,99 +66,58 @@ extension BundleEditView { } } - /// 키링 선택 시트 오버레이 - var keyringSheetOverlay: some View { - Group { - if showSelectKeyringSheet { - Color.black20 - .ignoresSafeArea() - .zIndex(1) - .onTapGesture { - withAnimation(.easeInOut) { - showSelectKeyringSheet = false - } - } - - VStack(spacing: 18) { - Text("키링 선택") - .typography(.suit16B) - .foregroundStyle(.black100) - - if isKeyringSheetLoading { - VStack { - LoadingAlert(type: .short40, message: nil) - .padding(.vertical, 24) - Text("키링을 불러오고 있어요") - .typography(.suit15R) - .foregroundStyle(.black100) - .padding(.vertical, 15) - } - .frame(maxWidth: .infinity) - .frame(height: screenHeight * (sheetHeightRatio - 0.08)) // 버튼 영역 제외한 대략 높이 - } else 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: bundleVM.selectedKeyrings, selectedPosition: selectedPosition), id: \.self) { keyring in - KeyringCell( - keyring: keyring, - isSelectedHere: bundleVM.selectedKeyrings[selectedPosition]?.id == keyring.id, - isSelectedElsewhere: bundleVM.selectedKeyrings.values.contains { $0.id == keyring.id } && !(bundleVM.selectedKeyrings[selectedPosition]?.id == keyring.id), - width: threeGridCellWidth, - height: threeGridCellHeight, - onTapSelect: { - // 기존 있으면 순서 제거 후 교체 - if bundleVM.selectedKeyrings[selectedPosition] != nil { - bundleVM.keyringOrder.removeAll { $0 == selectedPosition } - } - bundleVM.selectedKeyrings[selectedPosition] = keyring - bundleVM.keyringOrder.append(selectedPosition) - withAnimation(.easeInOut) { - showSelectKeyringSheet = false - } - updateKeyringDataList() - }, - onTapDeselect: { - bundleVM.selectedKeyrings[selectedPosition] = nil - bundleVM.keyringOrder.removeAll { $0 == selectedPosition } - withAnimation(.easeInOut) { - showSelectKeyringSheet = false - } - updateKeyringDataList() - } - ) - } - } - } + /// 키링 선택 시트 (Create와 동일한 SwiftUI sheet 방식) + var keyringSheetContent: some View { + KeyringSelectionContent( + searchText: $keyringSearchText, + keyrings: sortedKeyringsForSelection, + isLoading: isKeyringSheetLoading, + gridColumns: gridColumns, + cellWidth: threeGridCellWidth, + cellHeight: threeGridCellHeight, + isSelectedHere: { keyring in + bundleVM.selectedKeyrings[selectedPosition]?.id == keyring.id + }, + isSelectedElsewhere: { keyring in + bundleVM.selectedKeyrings.values.contains { $0.id == keyring.id } && + !(bundleVM.selectedKeyrings[selectedPosition]?.id == keyring.id) + }, + onTapSelect: { keyring in + // 다른 위치에 이미 장착된 키링인지 확인 + let existingPosition = bundleVM.selectedKeyrings.first { $0.value.id == keyring.id }?.key + + if let existingPos = existingPosition, existingPos != selectedPosition { + // 다른 위치에서 제거만 (현재 위치에 장착 X, 시트 유지) + bundleVM.selectedKeyrings[existingPos] = nil + bundleVM.keyringOrder.removeAll { $0 == existingPos } + } else { + // 새 키링 선택 → 현재 위치에 장착, 시트 닫기 + if bundleVM.selectedKeyrings[selectedPosition] != nil { + bundleVM.keyringOrder.removeAll { $0 == selectedPosition } } - + bundleVM.selectedKeyrings[selectedPosition] = keyring + bundleVM.keyringOrder.append(selectedPosition) + showSelectKeyringSheet = false } - .padding(EdgeInsets(top: 30, leading: 20, bottom: 0, trailing: 20)) - .frame(maxWidth: .infinity) - .frame(height: screenHeight * sheetHeightRatio) - .glassEffect(.regular, in: .rect) - .clipShape(UnevenRoundedRectangle(topLeadingRadius: 30, topTrailingRadius: 30)) - .shadow(radius: 10) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .transition(.move(edge: .bottom)) - .zIndex(2) - + updateKeyringDataList() + }, + onTapDeselect: { keyring in + bundleVM.selectedKeyrings[selectedPosition] = nil + bundleVM.keyringOrder.removeAll { $0 == selectedPosition } + showSelectKeyringSheet = false // 시트 닫기 + updateKeyringDataList() } - } + ) + .padding(.horizontal, 20) + .presentationDetents([.fraction(0.45), .fraction(0.85)]) + .presentationDragIndicator(.visible) + } + + // MARK: - 정렬된 키링 목록 (필터링은 KeyringSelectionContent에서 처리) + var sortedKeyringsForSelection: [Keyring] { + bundleVM.sortedKeyringsForSelection( + selectedKeyrings: bundleVM.selectedKeyrings, + selectedPosition: selectedPosition + ) } - } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift index a778ba165..6e0075c57 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleEditView.swift @@ -42,7 +42,8 @@ struct BundleEditView: View { @State var selectedPosition = 0 @State var sceneRefreshId = UUID() - + @State var keyringSearchText: String = "" + // 공통 그리드 컬럼 (배경, 카라비너, 키링 모두 동일) let gridColumns: [GridItem] = [ GridItem(.flexible(), spacing: 10), @@ -57,17 +58,14 @@ struct BundleEditView: View { let sheetHeightRatio: CGFloat = 0.43 var shouldApplyBlur: Bool { - showPurchaseFailAlert || showPurchaseSuccessAlert || isCapturing || !isSceneReady || bundleVM.isPurchasing || isKeyringSheetLoading + showPurchaseFailAlert || showPurchaseSuccessAlert || isCapturing || !isSceneReady || bundleVM.isPurchasing } var body: some View { ZStack { ZStack(alignment: .bottom) { mainContentView - - // 키링 선택 시트 - keyringSheetOverlay - + // 배경, 카라비너 선택 시트 selectItemSheetContent } @@ -81,6 +79,9 @@ struct BundleEditView: View { .sheet(isPresented: $showPurchaseSheet) { purchaseSheetView } + .sheet(isPresented: $showSelectKeyringSheet) { + keyringSheetContent + } .sheet(isPresented: $bundleVM.showSheetSortSheet) { WorkshopSortSheet( showSheet: $bundleVM.showSheetSortSheet, @@ -125,14 +126,6 @@ struct BundleEditView: View { // 카라비너 변경 시에는 키링 데이터 업데이트만 수행 (Firebase 접근 없음) updateKeyringDataList() } - // 키링 선택 시트 활성화 시 배경, 카라비너 선택 시트의 높이를 낮춤 - .onChange(of: showSelectKeyringSheet) { _, newValue in - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - if newValue { - sheetHeight = screenHeight * 0.08 - } - } - } } // MARK: - Main Content Views @@ -246,110 +239,6 @@ struct BundleEditView: View { } .ignoresSafeArea() } - - /// 키링 데이터 리스트 업데이트 - func updateKeyringDataList() { - guard let carabiner = bundleVM.newSelectedCarabiner?.carabiner else { - keyringDataList = [] - return - } - - let newData = bundleVM.createKeyringDataListFromSelected( - selectedKeyrings: bundleVM.selectedKeyrings, - keyringOrder: bundleVM.keyringOrder, - carabiner: carabiner - ) - - // 데이터가 실제로 변경된 경우에만 업데이트 - if keyringDataList != newData { - keyringDataList = newData - - // 키링이 추가/변경될 때도 씬을 새로고침하여 확실히 반영되도록 함 - if !newData.isEmpty { - sceneRefreshId = UUID() - } - } - } - - // MARK: - 썸네일 재캡쳐 & 캐시 저장 - private func recaptureAndCacheBundleThumbnail(bundleId: String, bundleName: String, createdAt: Date) async { - // 편집 중 상태로 캡쳐 - guard let bg = bundleVM.newSelectedBackground?.background, - let cb = bundleVM.newSelectedCarabiner?.carabiner else { - return - } - - await MainActor.run { - isCapturing = true - } - - // 캡쳐용 키링 데이터 생성 (편집 중 keyringDataList -> 캡쳐용으로 변환) - let captureKeyrings: [MultiKeyringCaptureScene.KeyringData] = keyringDataList.map { item in - MultiKeyringCaptureScene.KeyringData( - index: item.index, - position: item.position, - bodyImageURL: item.bodyImageURL, - hookOffsetY: item.hookOffsetY, - chainLength: item.chainLength - ) - } - - // 카라비너 타입 및 이미지 URL - let carabinerType = cb.type - let carabinerBackURL: String? - let carabinerFrontURL: String? - if carabinerType == .hamburger { - carabinerBackURL = cb.carabinerImage[1] - carabinerFrontURL = cb.carabinerImage[2] - } else { - carabinerBackURL = cb.carabinerImage[0] - carabinerFrontURL = nil - } - - // 1. 배경 포함 캡쳐 (앱용) - guard let fullImageData = await MultiKeyringCaptureScene.captureBundleImage( - keyringDataList: captureKeyrings, - backgroundImageURL: bg.backgroundImage, - carabinerBackImageURL: carabinerBackURL, - carabinerFrontImageURL: carabinerFrontURL, - carabinerType: carabinerType, - carabinerX: cb.carabinerX, - carabinerY: cb.carabinerY, - carabinerWidth: cb.carabinerWidth - ) else { - await MainActor.run { - isCapturing = false - } - return - } - - // 2. 배경 없이 캡쳐 (위젯용 - 투명 여백 제거 후 리사이즈) - let widgetImageData = await MultiKeyringCaptureScene.captureBundleImage( - keyringDataList: captureKeyrings, - backgroundImageURL: nil, - carabinerBackImageURL: carabinerBackURL, - carabinerFrontImageURL: carabinerFrontURL, - carabinerType: carabinerType, - carabinerX: cb.carabinerX, - carabinerY: cb.carabinerY, - carabinerWidth: cb.carabinerWidth, - trimTransparentEdges: true - ) - - // 캐시 저장 (full + widget) - BundleImageCache.shared.syncBundle( - id: bundleId, - name: bundleName, - fullImageData: fullImageData, - widgetImageData: widgetImageData, - createdAt: createdAt - ) - - await MainActor.run { - bundleVM.bundleCapturedImage = fullImageData - isCapturing = false - } - } } // MARK: - 툴바 diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleKeyringCellView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleKeyringCellView.swift new file mode 100644 index 000000000..86c0c8ed4 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleKeyringCellView.swift @@ -0,0 +1,162 @@ +// +// BundleKeyringCellView.swift +// Keychy +// +// Created by 길지훈 on 2/6/26. +// + +import SwiftUI +import SpriteKit + +/// Bundle 전용 키링 셀 뷰 (단순화 버전) +/// - 캐시된 이미지 표시 +/// - 캐시 없으면 Scene fallback +/// - 상태 오버레이 +struct BundleKeyringCellView: View { + let keyring: Keyring + let isSelected: Bool + + @State private var isLoading = true + @State private var cachedImage: UIImage? + @State private var scene: KeyringCellScene? + + var body: some View { + ZStack { + Color.white100 + + // 콘텐츠 + if let image = cachedImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else if let scene = scene { + SpriteView(scene: scene) + } + + // 로딩 + if isLoading && cachedImage == nil && scene == nil { + LoadingAlert(type: .short40, message: nil) + } + + // 상태 오버레이 (포장중/출품중) + if let info = keyring.status.overlayInfo { + statusOverlay(info: info) + } else { + // 선택 원 (선택 시 체크마크, 미선택 시 빈 원) + selectionCircle + } + } + .onAppear { loadContent() } + .onDisappear { cleanupScene() } + } + + // MARK: - 로딩 + private func loadContent() { + // 1. 캐시 확인 + if let keyringID = keyring.documentId, + let imageData = KeyringImageCache.shared.load(for: keyringID, type: .thumbnail), + let image = UIImage(data: imageData) { + cachedImage = image + isLoading = false + return + } + + // 2. 캐시 없으면 Scene 생성 + createScene() + } + + private func createScene() { + let newScene = KeyringCellScene( + ringType: RingType.fromID(keyring.selectedRing), + chainType: ChainType.fromID(keyring.selectedChain), + bodyImage: keyring.bodyImage, + templateId: keyring.selectedTemplate, + targetSize: CGSize(width: 175, height: 233), + zoomScale: 2.0, + hookOffsetY: keyring.hookOffsetY, + chainLength: keyring.chainLength, + onLoadingComplete: { + DispatchQueue.main.async { + isLoading = false + } + } + ) + newScene.scaleMode = .aspectFill + scene = newScene + } + + private func cleanupScene() { + scene?.removeAllChildren() + scene?.removeAllActions() + scene?.physicsWorld.removeAllJoints() + scene?.view?.presentScene(nil) + scene = nil + } + + // MARK: - 선택 원 + private var selectionCircle: some View { + VStack { + Spacer() + HStack { + Spacer() + if isSelected { + Circle() + .fill(.main500) + .frame(width: 26.14, height: 26.14) + .overlay( + Image(.checkMarkWhite) + ) + .overlay( + Circle() + .strokeBorder(.white100, lineWidth: 1) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 0) + ) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 0) + } else { + Circle() + .fill(.clear) + .frame(width: 26.14, height: 26.14) + .overlay( + Circle() + .strokeBorder(.white100, lineWidth: 1) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 0) + ) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 0) + } + } + } + .padding(6) + } + + // MARK: - 상태 오버레이 + private func statusOverlay(info: String) -> some View { + RoundedRectangle(cornerRadius: 10) + .fill(.black50) + .overlay { + VStack { + Text(info) + .typography(.suit12M) + .foregroundColor(keyring.status == .packaged ? .white100 : .main500) + .padding(.vertical, 4) + .frame(maxWidth: .infinity) + .background { + if keyring.status == .packaged { + RoundedRectangle(cornerRadius: 20) + .fill(Color.black60) + .frame(height: 23) + } else { + RoundedRectangle(cornerRadius: 20) + .fill(.gradient(.festivalPublished)) + .frame(height: 23) + .overlay( + RoundedRectangle(cornerRadius: 20) + .strokeBorder(.main50, lineWidth: 1) + ) + } + } + Spacer() + } + .padding(6) + } + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSearchBar.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSearchBar.swift new file mode 100644 index 000000000..9ce7e73c9 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleSearchBar.swift @@ -0,0 +1,38 @@ +// +// BundleSearchBar.swift +// Keychy +// +// Created by 길지훈 on 2/6/26. +// + +import SwiftUI + +struct BundleSearchBar: View { + @Binding var searchText: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Color(#colorLiteral(red: 0.6, green: 0.6, blue: 0.6, alpha: 1))) + + TextField("검색", text: $searchText) + .foregroundStyle(Color(#colorLiteral(red: 0.6, green: 0.6, blue: 0.6, alpha: 1))) + .autocorrectionDisabled() + + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.gray400) + } + } + } + .padding(.vertical, 10) + .padding(.horizontal, 20) + .background( + RoundedRectangle(cornerRadius: 100) + .fill(Color(#colorLiteral(red: 0.462745098, green: 0.462745098, blue: 0.5019607843, alpha: 0.12))) + ) + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringCell.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringCell.swift index e06576092..0fbf063d0 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringCell.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringCell.swift @@ -20,34 +20,25 @@ struct KeyringCell: View { Button { if isSelectedHere { onTapDeselect() - } else if !isSelectedElsewhere { - onTapSelect() } else { - // 중복 선택 방지: 아무 것도 하지 않음 + onTapSelect() } } label: { ZStack(alignment: .bottomTrailing) { - VStack(spacing: 10) { + VStack(spacing: 6) { ZStack { - CollectionCellView(keyring: keyring) + BundleKeyringCellView(keyring: keyring, isSelected: isSelectedHere || isSelectedElsewhere) .frame(width: width, height: height) .cornerRadius(10) // 선택 테두리 RoundedRectangle(cornerRadius: 10) - .strokeBorder(isSelectedHere ? .mainOpacity80 : .clear, lineWidth: 1.8) + .strokeBorder(isSelectedHere ? .main500 : .clear, lineWidth: 2) .frame(width: width, height: height) - - // 다른 위치에 장착된 경우 dim - if isSelectedElsewhere { - RoundedRectangle(cornerRadius: 10) - .fill(Color.black50) - .frame(width: width, height: height) - } } Text(keyring.name) - .typography(isSelectedHere ? .notosans14SB : .notosans14M) + .typography(isSelectedHere ? .notosans13SB : .notosans13M) .foregroundStyle(isSelectedHere ? .main500 : .black100) .lineLimit(1) .truncationMode(.tail) @@ -58,9 +49,9 @@ struct KeyringCell: View { VStack { HStack { Spacer() - Text("장착 중") + Text("장착중") .foregroundStyle(.white100) - .typography(.suit13M) + .typography(.suit12M) .padding(.horizontal, 10) .padding(.vertical, 4) .background( @@ -75,7 +66,7 @@ struct KeyringCell: View { } } } - .disabled(keyring.status == .packaged || keyring.status == .published || isSelectedElsewhere) + .disabled(keyring.status == .packaged || keyring.status == .published) .opacity(1.0) } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringEmptyStateView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringEmptyStateView.swift new file mode 100644 index 000000000..4ec3d1c23 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringEmptyStateView.swift @@ -0,0 +1,23 @@ +// +// KeyringEmptyStateView.swift +// Keychy +// +// Created by 길지훈 on 2/6/26. +// + +import SwiftUI + +struct KeyringEmptyStateView: View { + var body: some View { + VStack(spacing: 16) { + Image(.surprisedAlert) + + Text("공방에서 키링을 만들어보세요.\n아직 만들어진 키링이 없어요.") + .typography(.suit15R) + .foregroundStyle(.black100) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringSelectionContent.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringSelectionContent.swift new file mode 100644 index 000000000..6c9922d98 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/KeyringSelectionContent.swift @@ -0,0 +1,90 @@ +// +// KeyringSelectionContent.swift +// Keychy +// +// Created by 길지훈 on 2/6/26. +// + +import SwiftUI + +/// 키링 선택 시트의 내용물을 담당하는 공유 컴포넌트 +/// Create와 Edit에서 동일한 내용을 다른 시트 방식으로 감싸 사용 +struct KeyringSelectionContent: View { + @Binding var searchText: String + let keyrings: [Keyring] + let isLoading: Bool + let gridColumns: [GridItem] + let cellWidth: CGFloat + let cellHeight: CGFloat + + /// 현재 선택 위치에서 선택된 키링인지 확인 + let isSelectedHere: (Keyring) -> Bool + /// 다른 위치에서 이미 선택된 키링인지 확인 + let isSelectedElsewhere: (Keyring) -> Bool + /// 키링 선택 액션 + let onTapSelect: (Keyring) -> Void + /// 키링 선택 해제 액션 + let onTapDeselect: (Keyring) -> Void + + var body: some View { + if isLoading { + loadingView + } else if keyrings.isEmpty { + KeyringEmptyStateView() + } else { + contentView + } + } + + // MARK: - 로딩 뷰 + private var loadingView: some View { + VStack { + LoadingAlert(type: .short40, message: nil) + .padding(.vertical, 24) + Text("키링을 불러오고 있어요") + .typography(.suit15R) + .foregroundStyle(.black100) + .padding(.vertical, 15) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } + + // MARK: - 메인 콘텐츠 + private var contentView: some View { + VStack(spacing: 20) { + BundleSearchBar(searchText: $searchText) + .padding(.top, 45) + + ScrollView(showsIndicators: false) { + LazyVGrid(columns: gridColumns, spacing: 14) { + ForEach(filteredKeyrings, id: \.self) { keyring in + KeyringCell( + keyring: keyring, + isSelectedHere: isSelectedHere(keyring), + isSelectedElsewhere: isSelectedElsewhere(keyring), + width: cellWidth, + height: cellHeight, + onTapSelect: { + onTapSelect(keyring) + }, + onTapDeselect: { + onTapDeselect(keyring) + } + ) + } + } + } + } + } + + // MARK: - 필터링된 키링 목록 + private var filteredKeyrings: [Keyring] { + if searchText.isEmpty { + return keyrings + } + return keyrings.filter { keyring in + keyring.name.localizedCaseInsensitiveContains(searchText) + } + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/01. Colors/5. Gradient/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/01. Colors/5. Gradient/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/01. Colors/5. Gradient/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/01. Colors/5. Gradient/Gradient3.colorset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/01. Colors/5. Gradient/Gradient3.colorset/Contents.json new file mode 100644 index 000000000..ac2e387b6 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/01. Colors/5. Gradient/Gradient3.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xE8", + "red" : "0xF9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/01. Colors/5. Gradient/Gradient4.colorset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/01. Colors/5. Gradient/Gradient4.colorset/Contents.json new file mode 100644 index 000000000..54e9c2829 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/01. Colors/5. Gradient/Gradient4.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFE", + "green" : "0xFF", + "red" : "0xCE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Status/checkMarkWhite.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Status/checkMarkWhite.imageset/Contents.json new file mode 100644 index 000000000..8a1f88aa2 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Status/checkMarkWhite.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "checkMarkWhite.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Status/checkMarkWhite.imageset/checkMarkWhite.pdf b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Status/checkMarkWhite.imageset/checkMarkWhite.pdf new file mode 100644 index 000000000..f71b2c0bb Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/18. Icons/Status/checkMarkWhite.imageset/checkMarkWhite.pdf differ diff --git a/Keychy/Keychy/Resources/Assets.xcassets/21. Alert/surprisedAlert.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/21. Alert/surprisedAlert.imageset/Contents.json new file mode 100644 index 000000000..145a6f158 --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/21. Alert/surprisedAlert.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "suprisedAlert.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/21. Alert/surprisedAlert.imageset/suprisedAlert.pdf b/Keychy/Keychy/Resources/Assets.xcassets/21. Alert/surprisedAlert.imageset/suprisedAlert.pdf new file mode 100644 index 000000000..e8e41a50b Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/21. Alert/surprisedAlert.imageset/suprisedAlert.pdf differ