diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 258a72e66..77cbaf866 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -42,6 +42,14 @@ 386B17522ECCDFBA00CCCC23 /* KeyringCollectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386B17512ECCDFBA00CCCC23 /* KeyringCollectView.swift */; }; 386B17542ECCE8EC00CCCC23 /* CollectionViewModel+Distribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386B17532ECCE8EC00CCCC23 /* CollectionViewModel+Distribution.swift */; }; 386B17642ECD142600CCCC23 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386B17632ECD142600CCCC23 /* String+Extension.swift */; }; + 38818DB22F3C43CF00B0A49C /* DuZzonKuVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB12F3C43CF00B0A49C /* DuZzonKuVM.swift */; }; + 38818DB42F3C43DC00B0A49C /* DuZzonKuPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB32F3C43DC00B0A49C /* DuZzonKuPreview.swift */; }; + 38818DB62F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB52F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift */; }; + 38818DB82F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB72F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift */; }; + 38818DBA2F3C443B00B0A49C /* DuZzonKuVM+Effect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB92F3C443B00B0A49C /* DuZzonKuVM+Effect.swift */; }; + 38818DBC2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DBB2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift */; }; + 38818DBE2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DBD2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift */; }; + 38818DC02F3CBE2E00B0A49C /* CheckerBoardRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DBF2F3CBE2E00B0A49C /* CheckerBoardRect.swift */; }; 388E72942EF341F200AE1F1B /* CollectionViewModel+UserData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388E72932EF341F200AE1F1B /* CollectionViewModel+UserData.swift */; }; 389080172ED3F05D00D7A49F /* FestivalKeyringDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389080162ED3F05D00D7A49F /* FestivalKeyringDetailView.swift */; }; 389080192ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389080182ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift */; }; @@ -521,6 +529,14 @@ 386B17512ECCDFBA00CCCC23 /* KeyringCollectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringCollectView.swift; sourceTree = ""; }; 386B17532ECCE8EC00CCCC23 /* CollectionViewModel+Distribution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionViewModel+Distribution.swift"; sourceTree = ""; }; 386B17632ECD142600CCCC23 /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; + 38818DB12F3C43CF00B0A49C /* DuZzonKuVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuZzonKuVM.swift; sourceTree = ""; }; + 38818DB32F3C43DC00B0A49C /* DuZzonKuPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DuZzonKuPreview.swift; path = Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuPreview.swift; sourceTree = SOURCE_ROOT; }; + 38818DB52F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DuZzonKuFramePreviewView.swift; path = Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift; sourceTree = SOURCE_ROOT; }; + 38818DB72F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuZzonKuFrameSelectorView.swift; sourceTree = ""; }; + 38818DB92F3C443B00B0A49C /* DuZzonKuVM+Effect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DuZzonKuVM+Effect.swift"; sourceTree = ""; }; + 38818DBB2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DuZzonKuVM+Firebase.swift"; sourceTree = ""; }; + 38818DBD2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DuZzonKuVM+ImageConversion.swift"; sourceTree = ""; }; + 38818DBF2F3CBE2E00B0A49C /* CheckerBoardRect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckerBoardRect.swift; sourceTree = ""; }; 388E72932EF341F200AE1F1B /* CollectionViewModel+UserData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionViewModel+UserData.swift"; sourceTree = ""; }; 389080162ED3F05D00D7A49F /* FestivalKeyringDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FestivalKeyringDetailView.swift; sourceTree = ""; }; 389080182ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FestivalKeyringDetailView+Sheet.swift"; sourceTree = ""; }; @@ -1010,6 +1026,36 @@ path = Store; sourceTree = ""; }; + 38818DAE2F3C42AF00B0A49C /* DuZzonKu */ = { + isa = PBXGroup; + children = ( + 38818DB02F3C43B500B0A49C /* ViewModels */, + 38818DAF2F3C43B000B0A49C /* Views */, + ); + path = DuZzonKu; + sourceTree = ""; + }; + 38818DAF2F3C43B000B0A49C /* Views */ = { + isa = PBXGroup; + children = ( + 38818DB52F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift */, + 38818DB72F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift */, + 38818DB32F3C43DC00B0A49C /* DuZzonKuPreview.swift */, + ); + path = Views; + sourceTree = ""; + }; + 38818DB02F3C43B500B0A49C /* ViewModels */ = { + isa = PBXGroup; + children = ( + 38818DB12F3C43CF00B0A49C /* DuZzonKuVM.swift */, + 38818DB92F3C443B00B0A49C /* DuZzonKuVM+Effect.swift */, + 38818DBB2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift */, + 38818DBD2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; 388E72952EF34DA200AE1F1B /* Detail */ = { isa = PBXGroup; children = ( @@ -1620,6 +1666,7 @@ 4C4733842F1FA388005D2376 /* Polaroid */, 4C47338F2F1FA388005D2376 /* SpeechBubble */, 3896B95F2F3B960500220134 /* WishHorse26 */, + 38818DAE2F3C42AF00B0A49C /* DuZzonKu */, ); path = Templates; sourceTree = ""; @@ -2377,6 +2424,7 @@ children = ( 4CEC61DE2EAE08C00099ECEE /* KeyringTemplate.swift */, C67B755D2ECD526A00D6E3FA /* Frame.swift */, + 38818DBF2F3CBE2E00B0A49C /* CheckerBoardRect.swift */, ); path = Template; sourceTree = ""; @@ -2615,6 +2663,7 @@ 4C3687FC2EC05E6800C64E75 /* AccountAlert.swift in Sources */, 388E72942EF341F200AE1F1B /* CollectionViewModel+UserData.swift in Sources */, 4CEBB14D2EFAA52F00CF53E2 /* DeepLinkHandler.swift in Sources */, + 38818DC02F3CBE2E00B0A49C /* CheckerBoardRect.swift in Sources */, 4CEBB14E2EFAA52F00CF53E2 /* DeepLinkManager.swift in Sources */, 4CEC61E62EAE08C00099ECEE /* Keyring.swift in Sources */, 4CEC61F12EAE08C40099ECEE /* KeychyApp.swift in Sources */, @@ -2668,6 +2717,7 @@ 4CC8D0252EF11CD200317467 /* HomeViewModel.swift in Sources */, C68931CE2EB7B94B00C5F083 /* EffectManager.swift in Sources */, C6B56F202EBF72130049F969 /* ItemPurchaseManager.swift in Sources */, + 38818DBC2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift in Sources */, 38C3C28C2EC1E4B4003C5DE1 /* CollectionKeyringDetailView+Alerts.swift in Sources */, 4CAF11AB2EBF6058004CB08C /* CollectionKeyringDetailView+SaveImage.swift in Sources */, 4CA9C6A82EC9DB5300CA546B /* View+SafeAreaBottom.swift in Sources */, @@ -2680,6 +2730,7 @@ 3828F5492EC4CCE400F1B040 /* CollectionView+SearchMode.swift in Sources */, AA9B2E8B2EB001B70004D31C /* Carabiner.swift in Sources */, AA6298582EC457DF001576C0 /* CarabinerPopup.swift in Sources */, + 38818DB22F3C43CF00B0A49C /* DuZzonKuVM.swift in Sources */, AAA4467C2EC64C9900080AB1 /* SelectBackgroundSheet.swift in Sources */, 38C3C2842EC0D081003C5DE1 /* LinkCopiedPopup.swift in Sources */, C6C35F3E2ED2AB71009642F4 /* ZoomableScrollView.swift in Sources */, @@ -2697,6 +2748,7 @@ 4CA9C6A62EC9D11600CA546B /* CustomNavigationBar.swift in Sources */, 382800D32EC0628D005F1332 /* CollectionViewModel+Package.swift in Sources */, 38C3C28E2EC1F56B003C5DE1 /* CollectionKeyringDetailView+Sheet.swift in Sources */, + 38818DBA2F3C443B00B0A49C /* DuZzonKuVM+Effect.swift in Sources */, 4C6530462EBA80DA000F8154 /* PurchaseFailAlert.swift in Sources */, C6B56F602EC08BCF0049F969 /* WidgetKeyring.swift in Sources */, 4CEC622A2EAE08DA0099ECEE /* Font+Custom.swift in Sources */, @@ -2770,6 +2822,7 @@ 4C4733BC2F1FA388005D2376 /* PixelVM+ImageConversion.swift in Sources */, 4C4733BD2F1FA388005D2376 /* AcrylicPhotoGuiding.swift in Sources */, 4C4733BE2F1FA388005D2376 /* PolaroidVM+Effect.swift in Sources */, + 38818DB42F3C43DC00B0A49C /* DuZzonKuPreview.swift in Sources */, 4C4733BF2F1FA388005D2376 /* PolaroidVM.swift in Sources */, 4C4733C02F1FA388005D2376 /* ClearSketchPreview.swift in Sources */, 3896B9702F3BB2D600220134 /* Saddle.swift in Sources */, @@ -2830,6 +2883,7 @@ 4C4733232F1FA2AB005D2376 /* WorkshopView.swift in Sources */, 4C4733272F1FA2AB005D2376 /* WorkshopBundleBanner.swift in Sources */, 4C2525FB2F3B27B3003CC5AD /* MultiKeyringScene.swift in Sources */, + 38818DB62F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift in Sources */, 4C2525FC2F3B27B3003CC5AD /* MultiKeyringSceneView.swift in Sources */, 4C2525FF2F3B27B3003CC5AD /* MultiKeyringCaptureScene+Capture.swift in Sources */, 4C2526002F3B27B3003CC5AD /* MultiKeyringCaptureScene.swift in Sources */, @@ -2897,6 +2951,7 @@ 38F832CD2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift in Sources */, 38A22A9D2EC27AC400B4C7C5 /* PackagedKeyringView+SaveImage.swift in Sources */, 3861024A2F1129FA0045C529 /* KeyringCacheManager.swift in Sources */, + 38818DBE2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift in Sources */, AABA4DAC2ED2D4C700A7D062 /* cardPagerView.swift in Sources */, AA9115082EB126A60026E9BC /* CarabinerCell.swift in Sources */, 38283A832EBF554E00BE45A5 /* KeyringReceiveView.swift in Sources */, @@ -2915,6 +2970,7 @@ AA91150A2EB1B7930026E9BC /* AddKeyringButton.swift in Sources */, C645AEA32EB1B8FC004BFE69 /* DataInitializer.swift in Sources */, 4CA9C6F72ECBA45200CA546B /* KeychyNotification.swift in Sources */, + 38818DB82F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift in Sources */, 4CF2A9682F0B91F300BA9FDA /* AnimatedGIFView.swift in Sources */, 4C86A61D2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift in Sources */, 4CC8D01F2EF0447100317467 /* ChangeNameViewModel.swift in Sources */, diff --git a/Keychy/Keychy/CommonModels/Template/CheckerBoardRect.swift b/Keychy/Keychy/CommonModels/Template/CheckerBoardRect.swift new file mode 100644 index 000000000..2d4ad96b3 --- /dev/null +++ b/Keychy/Keychy/CommonModels/Template/CheckerBoardRect.swift @@ -0,0 +1,32 @@ +// +// CheckerBoardRect.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import Foundation + +/// 체커보드의 실제 영역 정보 (프레임 기준 비율) +struct CheckerBoardRect: Codable, Identifiable, Hashable { + /// 고유 ID (Firebase 자동 생성 또는 순서) + var id: String? + + /// X 위치 (프레임 너비 기준 비율, 0.0 ~ 1.0) + var x: CGFloat + + /// Y 위치 (프레임 높이 기준 비율, 0.0 ~ 1.0) + var y: CGFloat + + /// 너비 (프레임 너비 기준 비율, 0.0 ~ 1.0) + var width: CGFloat + + /// 높이 (프레임 높이 기준 비율, 0.0 ~ 1.0) + var height: CGFloat + + /// 모서리 radius + var cornerRadius: CGFloat? + + /// 순서 (여러 개일 때 정렬용) + var order: Int? +} diff --git a/Keychy/Keychy/CommonModels/Template/Frame.swift b/Keychy/Keychy/CommonModels/Template/Frame.swift index 77e74ed38..17302619b 100644 --- a/Keychy/Keychy/CommonModels/Template/Frame.swift +++ b/Keychy/Keychy/CommonModels/Template/Frame.swift @@ -16,6 +16,8 @@ struct Frame: Identifiable, Codable, Hashable { var type: String? // SpeechBubble 프레임 타입 (A, B, C) var order: Int? // 정렬 순서 var textOffsetY: CGFloat? // SpeechBubble 텍스트 Y 오프셋 + var checkerBoardURL: String? // DuZzonKu 프레임에 맞는 체커보드 + var checkerBoardRects: [CheckerBoardRect]? enum CodingKeys: String, CodingKey { case id @@ -25,5 +27,7 @@ struct Frame: Identifiable, Codable, Hashable { case type case order case textOffsetY + case checkerBoardURL + case checkerBoardRects } } diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift index 0eda2376d..3d8b644f2 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift @@ -28,7 +28,8 @@ enum KeyringScale { "ClearSketch": CGSize(width: 210, height: 210), "PixelKeyring": CGSize(width: 277, height: 257), "SpeechBubble": CGSize(width: 360, height: 249), - "WishHorse26": CGSize(width: 269, height: 269) + "WishHorse26": CGSize(width: 269, height: 269), + "DuZzonKu": CGSize(width: 376, height: 376) ] // MARK: - 템플릿 × 화면별 zoomScale @@ -38,7 +39,8 @@ enum KeyringScale { "ClearSketch": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], "PixelKeyring": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.9], "SpeechBubble": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.9], - "WishHorse26": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.85] + "WishHorse26": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.85], + "DuZzonKu": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.8] ] // MARK: - 카라비너별 뭉치 키링 스케일 diff --git a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift index 1bb42b796..63a8d0143 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift @@ -63,6 +63,12 @@ enum WorkshopRoute: Hashable, BundleRoute { case wishHorse26Customizing case wishHorse26InfoInput case wishHorse26Complete + + // MARK: - 두쫀쿠 키링 템플릿 + case duZzonKuPreview + case duZzonKuCustomizing + case duZzonKuInfoInput + case duZzonKuComplete // MARK: - 선물 포장 완료 case packageComplete(keyringDocumentId: String, postOfficeId: String, templateId: String, shareLink: String) @@ -85,6 +91,8 @@ enum WorkshopRoute: Hashable, BundleRoute { return .speechBubblePreview case "WishHorse26": return .wishHorse26Preview + case "DuZzonKu": + return .duZzonKuPreview default: return nil } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift new file mode 100644 index 000000000..b5be40cea --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift @@ -0,0 +1,381 @@ +// +// DuZzonKuFramePreviewView.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import PhotosUI +import NukeUI +import Nuke + +struct DuZzonKuFramePreviewView: View { + @Bindable var viewModel: DuZzonKuVM + let onSceneReady: () -> Void + + @State private var showPhotoSelectSheet = false + @State private var showPhotoPicker = false + @State private var showCamera = false + @State private var selectedPhotoItem: PhotosPickerItem? = nil + @State private var showEditButton = false + @State private var isFrameLoaded: Bool = false + + // 여러 개의 체커보드 중 어떤 것을 편집 중인지 + @State private var editingRectIndex: Int? = nil + + // 시트에서 선택한 액션을 저장 + @State private var pendingAction: PhotoAction? = nil + + enum PhotoAction { + case camera + case photoLibrary + } + + // 제스처 임시 값 + @State private var currentScale: CGFloat = 1.0 + @State private var currentRotation: Angle = .zero + @State private var currentOffset: CGSize = .zero + + // 크기 설정 + private let targetFrameHeight: CGFloat = 376 + + var body: some View { + GeometryReader { geometry in + ZStack { + // 메인 콘텐츠 + VStack { + ZStack(alignment: .top) { + // 프레임 + 안장 + 갈기 합성 영역 + VStack { + Spacer() + .frame(height: 135) + + compositionView + } + + // frameChain 이미지 (위에 겹침) + Image(.frameChain) + .resizable() + .scaledToFit() + .frame(width: 90) + .offset(y: 4) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 168) + .opacity(isFrameLoaded ? 1 : 0) + + // 로딩 중일 때 + if !isFrameLoaded { + LoadingAlert(type: .short40, message: nil) + } + } + } + .photosPicker( + isPresented: $showPhotoPicker, + selection: $selectedPhotoItem, + matching: .images + ) + .fullScreenCover(isPresented: $showCamera) { + CameraView { image in + // 현재 편집 중인 영역에 사진 저장 + if let index = editingRectIndex { + viewModel.setPhoto(image, at: index) + } else { + viewModel.selectedPhotoImage = image + } + + // 새 사진 선택 시 변환 초기화 + viewModel.photoScale = 1.0 + viewModel.photoRotation = .zero + viewModel.photoOffset = .zero + currentScale = 1.0 + currentRotation = .zero + currentOffset = .zero + showEditButton = false + editingRectIndex = nil + } + .ignoresSafeArea() + } + .sheet(isPresented: $showPhotoSelectSheet, onDismiss: { + // 시트가 완전히 닫힌 후 액션 실행 + if let action = pendingAction { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + switch action { + case .camera: + showCamera = true + case .photoLibrary: + showPhotoPicker = true + } + pendingAction = nil + } + } + }) { + PhotoSelectSheet( + onCameraSelected: { + pendingAction = .camera + }, + onPhotoLibrarySelected: { + pendingAction = .photoLibrary + } + ) + } + .onChange(of: selectedPhotoItem) { oldValue, newValue in + Task { + if let data = try? await newValue?.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data) { + // 현재 편집 중인 영역에 사진 저장 + if let index = editingRectIndex { + viewModel.setPhoto(uiImage, at: index) + } else { + viewModel.selectedPhotoImage = uiImage + } + + // 새 사진 선택 시 변환 초기화 + viewModel.photoScale = 1.0 + viewModel.photoRotation = .zero + viewModel.photoOffset = .zero + currentScale = 1.0 + currentRotation = .zero + currentOffset = .zero + showEditButton = false + editingRectIndex = nil + } + } + } + .onAppear { + // 일반 SwiftUI View는 즉시 준비 완료 + onSceneReady() + } + } + + private var photoGestures: some Gesture { + // 확대/축소 + let magnificationGesture = MagnificationGesture(minimumScaleDelta: 0.0) + .onChanged { value in + Task { @MainActor in + currentScale = value + } + } + .onEnded { value in + Task { @MainActor in + let newScale = viewModel.photoScale * value + viewModel.photoScale = min(max(newScale, 0.5), 3.0) + currentScale = 1.0 + } + } + + // 회전 + let rotationGesture = RotationGesture(minimumAngleDelta: .zero) + .onChanged { value in + Task { @MainActor in + currentRotation = value + } + } + .onEnded { value in + Task { @MainActor in + viewModel.photoRotation += value + currentRotation = .zero + } + } + + // 이동 + let dragGesture = DragGesture(minimumDistance: 10) + .onChanged { value in + Task { @MainActor in + currentOffset = CGSize( + width: value.translation.width, + height: value.translation.height + ) + } + } + .onEnded { value in + Task { @MainActor in + viewModel.photoOffset = CGSize( + width: viewModel.photoOffset.width + value.translation.width, + height: viewModel.photoOffset.height + value.translation.height + ) + currentOffset = .zero + } + } + + return magnificationGesture + .simultaneously(with: rotationGesture) + .simultaneously(with: dragGesture) + } + + private var finalScale: CGFloat { + let calculatedScale = viewModel.photoScale * currentScale + return min(max(calculatedScale, 0.5), 3.0) + } + + private var finalRotation: Angle { + viewModel.photoRotation + currentRotation + } + + private var finalOffset: CGSize { + CGSize( + width: viewModel.photoOffset.width + currentOffset.width, + height: viewModel.photoOffset.height + currentOffset.height + ) + } + + @ViewBuilder + private var compositionView: some View { + ZStack(alignment: .center) { + if let frame = viewModel.selectedFrame { + LazyImage(url: URL(string: frame.frameURL)) { state in + if state.isLoading { + LoadingAlert(type: .short40, message: nil) + .frame(height: targetFrameHeight) + } else if let image = state.image { + let frameAspect = (state.imageContainer?.image.size.width ?? 1) / (state.imageContainer?.image.size.height ?? 1) + let targetFrameWidth = targetFrameHeight * frameAspect + + ZStack(alignment: .topLeading) { + // 1. 여러 개의 체커보드/사진 영역 + if let checkerBoardRects = frame.checkerBoardRects { + ForEach(Array(checkerBoardRects.enumerated()), id: \.offset) { index, rect in + checkerBoardView( + rect: rect, + index: index, + targetFrameWidth: targetFrameWidth, + targetFrameHeight: targetFrameHeight, + checkerBoardURL: frame.checkerBoardURL + ) + } + } + + // 2. 프레임 이미지 + image + .resizable() + .scaledToFit() + .frame(height: targetFrameHeight) + .allowsHitTesting(false) + } + .frame(width: targetFrameWidth, height: targetFrameHeight) + .onAppear { + isFrameLoaded = true + } + } + } + .onDisappear { + isFrameLoaded = false + } + .onAppear { + print("checkerBoardRects:", frame.checkerBoardRects ?? []) + } + } + } + } + + @ViewBuilder + private func checkerBoardView( + rect: CheckerBoardRect, + index: Int, + targetFrameWidth: CGFloat, + targetFrameHeight: CGFloat, + checkerBoardURL: String? + ) -> some View { + // Firebase에서 정의한 체커보드 실제 영역 + let photoWidth = targetFrameWidth * rect.width + let photoHeight = targetFrameHeight * rect.height + let photoX = targetFrameWidth * rect.x + let photoY = targetFrameHeight * rect.y + + let radius = rect.cornerRadius ?? 0 + let clipShape = RoundedRectangle(cornerRadius: radius) + + ZStack { + // 체커보드 이미지 (선택사항, 디버깅용) + if let checkerBoardURLString = checkerBoardURL, + let checkerBoardURL = URL(string: checkerBoardURLString) { + LazyImage(url: checkerBoardURL) { checkerState in + if let checkerImage = checkerState.image { + checkerImage + .resizable() + .scaledToFill() + .frame(width: photoWidth, height: photoHeight) + .clipShape(clipShape) + } + } + } + + // 해당 영역에 저장된 사진 + if let photoImage = viewModel.getPhoto(at: index) { + ZStack { + Image(uiImage: photoImage) + .resizable() + .scaledToFill() + .frame(width: photoWidth, height: photoHeight) + .scaleEffect(finalScale) + .rotationEffect(finalRotation) + .offset(finalOffset) + .clipShape(clipShape) + + // 수정 버튼 표시 시 딤 처리 + if showEditButton && editingRectIndex == index { + Color.black20 + } + } + .frame(width: photoWidth, height: photoHeight) + .clipShape(clipShape) + .contentShape(clipShape) + .gesture(photoGestures) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + if editingRectIndex == index { + showEditButton.toggle() + } else { + editingRectIndex = index + showEditButton = true + } + } + } + } else { + // 사진이 없을 때 딤 오버레이 + Color.black20 + .frame(width: photoWidth, height: photoHeight) + .clipShape(clipShape) + } + + // 버튼들 + if viewModel.getPhoto(at: index) == nil { + // 플러스 버튼 + Button { + editingRectIndex = index + showPhotoSelectSheet = true + } label: { + Image(.plus) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .padding(5) + } + .glassEffect(.clear.interactive(), in: .circle) + .transition(.scale.combined(with: .opacity)) + } else if showEditButton && editingRectIndex == index { + // 연필 버튼 + Button { + showPhotoSelectSheet = true + showEditButton = false + } label: { + Image(.editPencil) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .padding(5) + } + .glassEffect(.clear.interactive(), in: .circle) + .transition(.scale.combined(with: .opacity)) + } + } + .position( + x: photoX + photoWidth / 2, + y: photoY + photoHeight / 2 + ) + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuPreview.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuPreview.swift new file mode 100644 index 000000000..dbe4dfc36 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuPreview.swift @@ -0,0 +1,29 @@ +// +// DuZzonKuPreview.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI + +struct DuZzonKuPreview: View { + @Bindable var router: NavigationRouter + @State var viewModel: DuZzonKuVM + @Environment(UserManager.self) private var userManager + + var body: some View { + TemplatePreviewBody( + template: viewModel.template, + fetchTemplate: { + await viewModel.fetchTemplate() + await viewModel.fetchFrames() + }, + onMake: { + router.push(.duZzonKuCustomizing) + }, + router: router + ) + .swipeBackGesture(enabled: true) + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Effect.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Effect.swift new file mode 100644 index 000000000..a84b3d9bb --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Effect.swift @@ -0,0 +1,196 @@ +// +// DuZzonKuVM+Effect.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import Combine + +extension DuZzonKuVM { + + // MARK: - Sorted Lists + + var sortedAvailableSounds: [Sound] { + availableSounds.sorted { sound1, sound2 in + guard let id1 = sound1.id, let id2 = sound2.id else { return false } + + let downloaded1 = isInBundle(soundId: id1) || isInCache(soundId: id1) + let downloaded2 = isInBundle(soundId: id2) || isInCache(soundId: id2) + + let priority1 = getSortPriority( + isFree: sound1.isFree, + isDownloaded: downloaded1 + ) + let priority2 = getSortPriority( + isFree: sound2.isFree, + isDownloaded: downloaded2 + ) + + return priority1 < priority2 + } + } + + var sortedAvailableParticles: [Particle] { + availableParticles.sorted { particle1, particle2 in + guard let id1 = particle1.id, let id2 = particle2.id else { return false } + + let downloaded1 = isInBundle(particleId: id1) || isInCache(particleId: id1) + let downloaded2 = isInBundle(particleId: id2) || isInCache(particleId: id2) + + let priority1 = getSortPriority( + isFree: particle1.isFree, + isDownloaded: downloaded1 + ) + let priority2 = getSortPriority( + isFree: particle2.isFree, + isDownloaded: downloaded2 + ) + + return priority1 < priority2 + } + } + + // MARK: - Sound & Particle Update + + func updateSound(_ sound: Sound?) { + selectedSound = sound + customSoundURL = nil + + if let sound = sound, let id = sound.id { + soundId = id + } else { + soundId = "none" + } + + effectSubject.send((soundId: soundId, particleId: particleId, type: .sound)) + } + + func updateParticle(_ particle: Particle?) { + selectedParticle = particle + + if let particle = particle, let id = particle.id { + particleId = id + } else { + particleId = "none" + } + + effectSubject.send((soundId: soundId, particleId: particleId, type: .particle)) + } + + // MARK: - Custom Sound (녹음) + + var hasCustomSound: Bool { + customSoundURL != nil + } + + func applyCustomSound(_ url: URL) { + customSoundURL = url + selectedSound = nil + soundId = "custom_recording" + effectSubject.send((soundId: soundId, particleId: particleId, type: .sound)) + } + + func removeCustomSound() { + customSoundURL = nil + soundId = "none" + effectSubject.send((soundId: soundId, particleId: particleId, type: .sound)) + } + + // MARK: - Ownership Check + + func isOwned(soundId: String) -> Bool { + return EffectManager.shared.isOwned(soundId: soundId, userManager: userManager) + } + + func isOwned(particleId: String) -> Bool { + return EffectManager.shared.isOwned(particleId: particleId, userManager: userManager) + } + + func isInBundle(soundId: String) -> Bool { + return EffectManager.shared.isInBundle(soundId: soundId) + } + + func isInBundle(particleId: String) -> Bool { + return EffectManager.shared.isInBundle(particleId: particleId) + } + + func isInCache(soundId: String) -> Bool { + return EffectManager.shared.isInCache(soundId: soundId) + } + + func isInCache(particleId: String) -> Bool { + return EffectManager.shared.isInCache(particleId: particleId) + } + + // MARK: - Download + + func downloadSound(_ sound: Sound) async { + guard let soundId = sound.id else { return } + + await MainActor.run { + downloadingItemIds.insert(soundId) + downloadProgress[soundId] = 0.0 + } + + let monitorTask = Task { + while !Task.isCancelled { + await MainActor.run { + if let progress = EffectManager.shared.downloadProgress[soundId] { + downloadProgress[soundId] = progress + } + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + await EffectManager.shared.downloadSound(sound, userManager: userManager) + monitorTask.cancel() + + await MainActor.run { + downloadingItemIds.remove(soundId) + downloadProgress.removeValue(forKey: soundId) + updateSound(sound) + } + } + + func downloadParticle(_ particle: Particle) async { + guard let particleId = particle.id else { return } + + await MainActor.run { + downloadingItemIds.insert(particleId) + downloadProgress[particleId] = 0.0 + } + + let monitorTask = Task { + while !Task.isCancelled { + await MainActor.run { + if let progress = EffectManager.shared.downloadProgress[particleId] { + downloadProgress[particleId] = progress + } + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + await EffectManager.shared.downloadParticle(particle, userManager: userManager) + monitorTask.cancel() + + await MainActor.run { + downloadingItemIds.remove(particleId) + downloadProgress.removeValue(forKey: particleId) + updateParticle(particle) + } + } + + // MARK: - Sorting Helper + + private func getSortPriority(isFree: Bool, isDownloaded: Bool) -> Int { + if isFree && isDownloaded { return 1 } + if !isFree && isDownloaded { return 2 } + if isFree && !isDownloaded { return 3 } + if !isFree && !isDownloaded { return 4 } + return 99 + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift new file mode 100644 index 000000000..b6d4fa9e2 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift @@ -0,0 +1,109 @@ +// +// DuZzonKuVM+Firebase.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import FirebaseFirestore + +extension DuZzonKuVM { + // MARK: - Firebase Template 가져오기 + func fetchTemplate() async { + isLoadingTemplate = true + defer { isLoadingTemplate = false } + + do { + let document = try await Firestore.firestore() + .collection("Template") + .document("DuZzonKu") + .getDocument() + + template = try document.data(as: KeyringTemplate.self) + hookOffsetY = template?.hookOffsetY ?? 0.0 + + } catch { + errorMessage = "템플릿을 불러오는데 실패했습니다." + } + } + + // MARK: - Firebase Effects 가져오기 + func fetchEffects() async { + guard let user = userManager.currentUser else { + errorMessage = "유저 정보를 불러올 수 없습니다." + return + } + + do { + // Sound 전체 가져오기 + let soundsSnapshot = try await Firestore.firestore() + .collection("Sound") + .whereField("isActive", isEqualTo: true) + .getDocuments() + + let allSounds = try soundsSnapshot.documents.compactMap { + try $0.data(as: Sound.self) + } + + let ownedSounds = allSounds.filter { sound in + guard let id = sound.id else { return false } + return user.soundEffects.contains(id) + } + let notOwnedSounds = allSounds.filter { sound in + guard let id = sound.id else { return false } + return !user.soundEffects.contains(id) + } + + availableSounds = ownedSounds + notOwnedSounds + + // Particle 전체 가져오기 + let particlesSnapshot = try await Firestore.firestore() + .collection("Particle") + .whereField("isActive", isEqualTo: true) + .getDocuments() + + let allParticles = try particlesSnapshot.documents.compactMap { + try $0.data(as: Particle.self) + } + + let ownedParticles = allParticles.filter { particle in + guard let id = particle.id else { return false } + return user.particleEffects.contains(id) + } + let notOwnedParticles = allParticles.filter { particle in + guard let id = particle.id else { return false } + return !user.particleEffects.contains(id) + } + + availableParticles = ownedParticles + notOwnedParticles + + } catch { + errorMessage = "이펙트 목록을 불러오는데 실패했습니다." + } + } + + // MARK: - Firebase Frames 가져오기 + func fetchFrames() async { + do { + let framesSnapshot = try await Firestore.firestore() + .collection("Template") + .document("DuZzonKu") + .collection("Frames") + .order(by: "order", descending: false) + .getDocuments() + + availableFrames = try framesSnapshot.documents.compactMap { + try $0.data(as: Frame.self) + } + + // 첫 번째 프레임을 기본 선택 + if let firstFrame = availableFrames.first { + selectedFrame = firstFrame + } + + } catch { + errorMessage = "프레임 목록을 불러오는데 실패했습니다." + } + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift new file mode 100644 index 000000000..84d370115 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift @@ -0,0 +1,162 @@ +// +// DuZzonKuVM+ImageConversion.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import Nuke + +extension DuZzonKuVM { + + // MARK: - Photo + Frame Composition (여러 개 지원) + + /// 선택한 사진들과 프레임을 합성하여 bodyImage로 저장 + func composePhotoWithFrame() async { + guard let frame = selectedFrame, + let frameURL = URL(string: frame.frameURL) else { + return + } + + // 합성 시작 + await MainActor.run { + isComposingPhoto = true + } + + defer { + Task { @MainActor in + isComposingPhoto = false + } + } + + // 프레임 이미지 다운로드 + guard let originalFrameImage = await downloadImage(from: frameURL) else { + return + } + + // 체커보드 이미지 다운로드 (선택사항) + var checkerboardImage: UIImage? = nil + if let checkerBoardURLString = frame.checkerBoardURL, + let checkerBoardURL = URL(string: checkerBoardURLString) { + checkerboardImage = await downloadImage(from: checkerBoardURL) + } + + // DuZzonKuFramePreviewView와 동일한 크기로 합성 + let targetFrameHeight: CGFloat = 376 + let frameAspect = originalFrameImage.size.width / originalFrameImage.size.height + let targetFrameWidth = targetFrameHeight * frameAspect + let targetFrameSize = CGSize(width: targetFrameWidth, height: targetFrameHeight) + + // Firebase에서 정의한 체커보드 영역들 + guard let checkerBoardRects = frame.checkerBoardRects else { + // checkerBoardRects가 없으면 프레임만 저장 + bodyImage = originalFrameImage + return + } + + let renderer = UIGraphicsImageRenderer(size: targetFrameSize) + + let composedImage = renderer.image { context in + + // 1. 각 체커보드 영역에 체커보드 + 사진 그리기 + for (index, rect) in checkerBoardRects.enumerated() { + let photoWidth = targetFrameWidth * rect.width + let photoHeight = targetFrameHeight * rect.height + let photoX = targetFrameWidth * rect.x + let photoY = targetFrameHeight * rect.y + let photoRect = CGRect(x: photoX, y: photoY, width: photoWidth, height: photoHeight) + + // 1-1. 체커보드 배경 (선택사항) + if let checkerboard = checkerboardImage { + context.cgContext.saveGState() + context.cgContext.addRect(photoRect) + context.cgContext.clip() + checkerboard.draw(in: photoRect) + context.cgContext.restoreGState() + } + + // 1-2. 해당 인덱스의 사진 그리기 + if let photo = getPhoto(at: index) { + context.cgContext.saveGState() + context.cgContext.addRect(photoRect) + context.cgContext.clip() + + // 사진을 영역에 맞게 scaledToFill로 그리기 + let photoAspect = photo.size.width / photo.size.height + let rectAspect = photoRect.width / photoRect.height + + var drawRect = photoRect + if photoAspect > rectAspect { + // 사진이 더 넓음 - 높이 기준으로 맞춤 + let scaledWidth = photoRect.height * photoAspect + drawRect = CGRect( + x: photoRect.midX - scaledWidth / 2, + y: photoRect.minY, + width: scaledWidth, + height: photoRect.height + ) + } else { + // 사진이 더 높음 - 너비 기준으로 맞춤 + let scaledHeight = photoRect.width / photoAspect + drawRect = CGRect( + x: photoRect.minX, + y: photoRect.midY - scaledHeight / 2, + width: photoRect.width, + height: scaledHeight + ) + } + + // 사진 변환 적용 (확대/축소, 회전, 이동) + // 현재는 모든 사진에 동일한 변환 적용 + // 필요시 인덱스별로 다른 변환 저장 가능 + let centerX = drawRect.midX + let centerY = drawRect.midY + + context.cgContext.translateBy(x: centerX, y: centerY) + context.cgContext.translateBy(x: photoOffset.width, y: photoOffset.height) + context.cgContext.rotate(by: CGFloat(photoRotation.radians)) + context.cgContext.scaleBy(x: photoScale, y: photoScale) + + let centeredRect = CGRect( + x: -drawRect.width / 2, + y: -drawRect.height / 2, + width: drawRect.width, + height: drawRect.height + ) + photo.draw(in: centeredRect) + context.cgContext.restoreGState() + } + + // 2. 프레임 이미지 + originalFrameImage.draw(in: CGRect(origin: .zero, size: targetFrameSize)) + } + } + + bodyImage = composedImage + } + + // MARK: - Helper: Download Image + + private func downloadImage(from url: URL) async -> UIImage? { + // Bundle에서 먼저 확인 (로컬 이미지인 경우) + if url.scheme == nil || url.scheme == "file" { + let imageName = url.lastPathComponent.replacingOccurrences(of: ".png", with: "") + return UIImage(named: imageName) + } + + // 원격 이미지 다운로드 + return await withCheckedContinuation { continuation in + Task { + do { + let imageRequest = ImageRequest(url: url) + let response = try await ImagePipeline.shared.image(for: imageRequest) + continuation.resume(returning: response) + } catch { + print("Failed to download image: \(error)") + continuation.resume(returning: nil) + } + } + } + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift new file mode 100644 index 000000000..0a0884f13 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift @@ -0,0 +1,184 @@ +// +// DuZzonKuVM.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import Combine +import FirebaseFirestore + +@Observable +class DuZzonKuVM: KeyringViewModelProtocol { + // MARK: - Template Data + var template: KeyringTemplate? + var isLoadingTemplate = false + + // MARK: - Effect Data + var availableSounds: [Sound] = [] + var availableParticles: [Particle] = [] + var selectedSound: Sound? = nil + var selectedParticle: Particle? = nil + var customSoundURL: URL? = nil + var downloadingItemIds: Set = [] + var downloadProgress: [String: Double] = [:] + var soundId: String = "none" + var particleId: String = "none" + let effectSubject = PassthroughSubject<(soundId: String, particleId: String, type: KeyringUpdateType), Never>() + + // MARK: - Frame Data + var availableFrames: [Frame] = [] + var selectedFrame: Frame? = nil + + // MARK: - Photo Data + var selectedPhotoImage: UIImage? = nil + var photoImages: [Int: UIImage] = [:] // 인덱스별 사진 저장 + + // MARK: - Photo Transform State + var photoScale: CGFloat = 1.0 + var photoRotation: Angle = .zero + var photoOffset: CGSize = .zero + + // MARK: - Body Image + var bodyImage: UIImage? = nil + var hookOffsetY: CGFloat = 0.0 + var isComposingPhoto: Bool = false + var isComposing: Bool { isComposingPhoto } + + // MARK: - Info Data + var nameText: String = "" + var maxTextCount: Int = 10 + var memoText: String = "" + var maxMemoCount: Int = 500 + var selectedTags: [String] = [] + var createdAt: Date = Date() + var savedKeyringDocumentId: String? + var packagedPostOfficeId: String? + var packagedShareLink: String? + + // MARK: - Dependencies + var userManager: UserManager + var errorMessage: String? + + // MARK: - Template Info + var templateId: String { template?.id ?? "DuZzonKu" } + var chainLength: Int { template?.chainLength ?? 3 } + + // MARK: - Customizing Modes + var availableCustomizingModes: [CustomizingMode] { [.frame, .effect] } + + // MARK: - 초기화 + init(userManager: UserManager = UserManager.shared) { + self.userManager = userManager + } + + // MARK: - Photo Management (여러 개 지원) + /// 특정 인덱스의 사진 가져오기 + func getPhoto(at index: Int) -> UIImage? { + return photoImages[index] + } + + /// 특정 인덱스에 사진 저장 + func setPhoto(_ image: UIImage, at index: Int) { + photoImages[index] = image + // 첫 번째 사진은 selectedPhotoImage에도 저장 (하위 호환성) + if index == 0 { + selectedPhotoImage = image + } + } + + /// 특정 인덱스의 사진 제거 + func removePhoto(at index: Int) { + photoImages.removeValue(forKey: index) + if index == 0 { + selectedPhotoImage = nil + } + } + + // MARK: - View Providers + func sceneView(for mode: CustomizingMode, onSceneReady: @escaping () -> Void) -> AnyView { + switch mode { + case .effect: + return AnyView(KeyringSceneView(viewModel: self, onSceneReady: onSceneReady)) + case .frame: + return AnyView(DuZzonKuFramePreviewView(viewModel: self, onSceneReady: onSceneReady)) + default: + return AnyView(EmptyView()) + } + } + + func bottomContentView( + for mode: CustomizingMode, + showPurchaseSheet: Binding, + cartItems: Binding<[EffectItem]> + ) -> AnyView { + switch mode { + case .effect: + return AnyView(EffectSelectorView(viewModel: self, cartItems: cartItems)) + case .frame: + return AnyView(DuZzonKuFrameSelectorView(viewModel: self)) + default: + return AnyView(EmptyView()) + } + } + + func bottomViewHeightRatio(for mode: CustomizingMode) -> CGFloat { + switch mode { + case .frame: + return 0.26 // 프레임 모드는 더 낮은 높이 + case .effect: + return 0.3 // 이펙트 모드도 같은 높이 + default: + return 0.35 + } + } + + // MARK: - Lifecycle Callbacks + /// 모드 변경 시 프레임 → 다른 모드로 전환되면 사진과 프레임 합성 + func onModeChanged(from oldMode: CustomizingMode, to newMode: CustomizingMode) { + if oldMode == .frame && newMode != .frame { + Task { + await composePhotoWithFrame() + } + } + } + + /// 다음 화면으로 이동하기 전 사진과 프레임 합성 + func beforeNavigateToNext() { + Task { + await composePhotoWithFrame() + } + } + + // MARK: - Reset + func resetCustomizingData() { + selectedSound = nil + selectedParticle = nil + customSoundURL = nil + soundId = "none" + particleId = "none" + downloadingItemIds.removeAll() + downloadProgress.removeAll() + selectedFrame = nil + selectedPhotoImage = nil + photoImages.removeAll() + photoScale = 1.0 + photoRotation = .zero + photoOffset = .zero + bodyImage = nil + availableFrames.removeAll() + isComposingPhoto = false + } + + func resetInfoData() { + nameText = "" + memoText = "" + selectedTags = [] + } + + func resetAll() { + resetCustomizingData() + resetInfoData() + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift new file mode 100644 index 000000000..c32c2979d --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift @@ -0,0 +1,95 @@ +// +// DuZzonKuFrameSelectorView.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import NukeUI + +struct DuZzonKuFrameSelectorView: View { + @Bindable var viewModel: DuZzonKuVM + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // MARK: - 프레임 섹션 + Text("프레임") + .typography(.suit16B) + .foregroundStyle(.black100) + .padding(.leading, 20) + .padding(.top, 20) + .padding(.bottom, 8) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(viewModel.availableFrames) { frame in + frameCell(frame: frame) + } + } + .padding(.horizontal, 20) + } + .frame(height: 130) + + Spacer() + } + .background( + UnevenRoundedRectangle( + topLeadingRadius: 24, + topTrailingRadius: 24 + ) + .fill(.white100) + .shadow(color: .black.opacity(0.15), radius: 9) + ) + .background(Color.gray50.ignoresSafeArea(edges: .bottom)) + } + + // MARK: - Frame Cell + + @ViewBuilder + private func frameCell(frame: Frame) -> some View { + let isSelected = viewModel.selectedFrame?.id == frame.id + + Button { + viewModel.selectedFrame = frame + } label: { + VStack(spacing: 6) { + LazyImage(url: URL(string: frame.thumbnailURL)) { state in + if let image = state.image { + ZStack { + Color.gray50 + + image + .resizable() + .scaledToFit() + .padding(.vertical, 8) + } + .frame(width: 105, height: 105) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray100) + .frame(width: 105, height: 105) + } + } + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + isSelected ? Color.main500 : Color.clear, + lineWidth: 2.5 + ) + ) + + // 프레임 이름 + Text(frame.name) + .typography(isSelected ? .notosans14SB : .notosans14R) + .foregroundStyle(isSelected ? .main500 : .black100) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: 70) + } + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift index a74c11935..8e9941027 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift @@ -51,7 +51,6 @@ struct WishHorse26FramePreviewView: View { } } } - .dismissKeyboardOnTap() .onAppear { // 일반 SwiftUI View는 즉시 준비 완료 onSceneReady() diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index b2eee3961..6320fe7c5 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -18,6 +18,7 @@ struct WorkshopTab: View { @State private var pixelKeyringVM: PixelVM? @State private var speechBubbleVM: SpeechBubbleVM? @State private var wishHorse26VM: WishHorse26VM? + @State private var duZzonKuVM: DuZzonKuVM? @State private var workshopViewModel = WorkshopViewModel(userManager: UserManager.shared) var body: some View { @@ -208,6 +209,28 @@ struct WorkshopTab: View { viewModel: getWishHorse26VM(), navigationTitle: "키링이 완성되었어요!" ) + + // MARK: - DuZzonKu + case .duZzonKuPreview: + DuZzonKuPreview(router: router, viewModel: getDuZzonKuVM()) + case .duZzonKuCustomizing: + KeyringCustomizingView( + router: router, + viewModel: getDuZzonKuVM(), + nextRoute: .duZzonKuInfoInput + ) + case .duZzonKuInfoInput: + KeyringInfoInputView( + router: router, + viewModel: getDuZzonKuVM(), + nextRoute: .duZzonKuComplete + ) + case .duZzonKuComplete: + KeyringCompleteView( + router: router, + viewModel: getDuZzonKuVM(), + navigationTitle: "키링이 완성되었어요!" + ) // MARK: - 선물 포장 완료 case .packageComplete(let keyringDocumentId, let postOfficeId, let templateId, let shareLink): @@ -291,6 +314,15 @@ struct WorkshopTab: View { } return viewModel } + + private func getDuZzonKuVM() -> DuZzonKuVM { + guard let viewModel = duZzonKuVM else { + let newViewModel = DuZzonKuVM() + duZzonKuVM = newViewModel + return newViewModel + } + return viewModel + } // MARK: - ViewModel by TemplateId private func getViewModelForTemplate(_ templateId: String) -> any KeyringViewModelProtocol { @@ -305,6 +337,8 @@ struct WorkshopTab: View { return getPixelKeyringVM() case "SpeechBubble": return getSpeechBubbleVM() + case "DuZzonKu": + return getDuZzonKuVM() default: return getPolaroidVM() }