diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 90cd5734..258a72e6 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -47,6 +47,15 @@ 389080192ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389080182ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift */; }; 3890801B2ED3F3BB00D7A49F /* FestivalKeyringDetailView+Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3890801A2ED3F3BB00D7A49F /* FestivalKeyringDetailView+Alerts.swift */; }; 389080632ED47F2500D7A49F /* VotePopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389080622ED47F2500D7A49F /* VotePopup.swift */; }; + 3896B9632F3B970100220134 /* WishHorse26Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896B9622F3B970100220134 /* WishHorse26Preview.swift */; }; + 3896B9652F3B974500220134 /* WishHorse26VM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896B9642F3B974500220134 /* WishHorse26VM.swift */; }; + 3896B9672F3B99CB00220134 /* WishHorse26VM+Effect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896B9662F3B99CB00220134 /* WishHorse26VM+Effect.swift */; }; + 3896B9692F3B9AB800220134 /* WishHorse26VM+Firebase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896B9682F3B9AB800220134 /* WishHorse26VM+Firebase.swift */; }; + 3896B96B2F3BA16200220134 /* WishHorse26FramePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896B96A2F3BA16200220134 /* WishHorse26FramePreviewView.swift */; }; + 3896B96D2F3BA1A700220134 /* WishHorse26FrameSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896B96C2F3BA1A700220134 /* WishHorse26FrameSelectorView.swift */; }; + 3896B9702F3BB2D600220134 /* Saddle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896B96F2F3BB2D600220134 /* Saddle.swift */; }; + 3896B9722F3BB2DD00220134 /* Mane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896B9712F3BB2DD00220134 /* Mane.swift */; }; + 3896B9742F3BB4D500220134 /* WishHorse26VM+ImageConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3896B9732F3BB4D500220134 /* WishHorse26VM+ImageConversion.swift */; }; 38A22A7F2EC2238800B4C7C5 /* CollectionKeyringPackageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A22A7E2EC2238800B4C7C5 /* CollectionKeyringPackageView.swift */; }; 38A22A9D2EC27AC400B4C7C5 /* PackagedKeyringView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A22A9C2EC27AC400B4C7C5 /* PackagedKeyringView+SaveImage.swift */; }; 38A22A9F2EC28B3D00B4C7C5 /* PackageCompleteView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A22A9E2EC28B3D00B4C7C5 /* PackageCompleteView+SaveImage.swift */; }; @@ -517,6 +526,15 @@ 389080182ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FestivalKeyringDetailView+Sheet.swift"; sourceTree = ""; }; 3890801A2ED3F3BB00D7A49F /* FestivalKeyringDetailView+Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FestivalKeyringDetailView+Alerts.swift"; sourceTree = ""; }; 389080622ED47F2500D7A49F /* VotePopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotePopup.swift; sourceTree = ""; }; + 3896B9622F3B970100220134 /* WishHorse26Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WishHorse26Preview.swift; sourceTree = ""; }; + 3896B9642F3B974500220134 /* WishHorse26VM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WishHorse26VM.swift; sourceTree = ""; }; + 3896B9662F3B99CB00220134 /* WishHorse26VM+Effect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WishHorse26VM+Effect.swift"; sourceTree = ""; }; + 3896B9682F3B9AB800220134 /* WishHorse26VM+Firebase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WishHorse26VM+Firebase.swift"; sourceTree = ""; }; + 3896B96A2F3BA16200220134 /* WishHorse26FramePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WishHorse26FramePreviewView.swift; sourceTree = ""; }; + 3896B96C2F3BA1A700220134 /* WishHorse26FrameSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WishHorse26FrameSelectorView.swift; sourceTree = ""; }; + 3896B96F2F3BB2D600220134 /* Saddle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Saddle.swift; sourceTree = ""; }; + 3896B9712F3BB2DD00220134 /* Mane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mane.swift; sourceTree = ""; }; + 3896B9732F3BB4D500220134 /* WishHorse26VM+ImageConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WishHorse26VM+ImageConversion.swift"; sourceTree = ""; }; 38A22A7E2EC2238800B4C7C5 /* CollectionKeyringPackageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionKeyringPackageView.swift; sourceTree = ""; }; 38A22A9C2EC27AC400B4C7C5 /* PackagedKeyringView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PackagedKeyringView+SaveImage.swift"; sourceTree = ""; }; 38A22A9E2EC28B3D00B4C7C5 /* PackageCompleteView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PackageCompleteView+SaveImage.swift"; sourceTree = ""; }; @@ -1041,6 +1059,46 @@ path = Package; sourceTree = ""; }; + 3896B95F2F3B960500220134 /* WishHorse26 */ = { + isa = PBXGroup; + children = ( + 3896B96E2F3BB2B700220134 /* Models */, + 3896B9602F3B96C000220134 /* ViewModels */, + 3896B9612F3B96D900220134 /* Views */, + ); + path = WishHorse26; + sourceTree = ""; + }; + 3896B9602F3B96C000220134 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 3896B9642F3B974500220134 /* WishHorse26VM.swift */, + 3896B9662F3B99CB00220134 /* WishHorse26VM+Effect.swift */, + 3896B9682F3B9AB800220134 /* WishHorse26VM+Firebase.swift */, + 3896B9732F3BB4D500220134 /* WishHorse26VM+ImageConversion.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 3896B9612F3B96D900220134 /* Views */ = { + isa = PBXGroup; + children = ( + 3896B96A2F3BA16200220134 /* WishHorse26FramePreviewView.swift */, + 3896B96C2F3BA1A700220134 /* WishHorse26FrameSelectorView.swift */, + 3896B9622F3B970100220134 /* WishHorse26Preview.swift */, + ); + path = Views; + sourceTree = ""; + }; + 3896B96E2F3BB2B700220134 /* Models */ = { + isa = PBXGroup; + children = ( + 3896B96F2F3BB2D600220134 /* Saddle.swift */, + 3896B9712F3BB2DD00220134 /* Mane.swift */, + ); + path = Models; + sourceTree = ""; + }; 38A596702EAFA8880003D712 /* Intro */ = { isa = PBXGroup; children = ( @@ -1561,6 +1619,7 @@ 4C47337A2F1FA388005D2376 /* Pixel */, 4C4733842F1FA388005D2376 /* Polaroid */, 4C47338F2F1FA388005D2376 /* SpeechBubble */, + 3896B95F2F3B960500220134 /* WishHorse26 */, ); path = Templates; sourceTree = ""; @@ -2598,6 +2657,7 @@ 38A5967A2EAFA94E0003D712 /* IntroView.swift in Sources */, 4C004FB52F18D98C00D9063E /* ReviewManager.swift in Sources */, 38283A7F2EBD3E8400BE45A5 /* PackageCompleteView.swift in Sources */, + 3896B9692F3B9AB800220134 /* WishHorse26VM+Firebase.swift in Sources */, 4CEC62202EAE08DA0099ECEE /* View+Extension.swift in Sources */, 4C4733E52F20FE34005D2376 /* WorkshopRecentTemplate.swift in Sources */, 38173D0C2EB8AD8800E36F7E /* CategoryContextMenu.swift in Sources */, @@ -2632,6 +2692,7 @@ C6C4028D2EB2741D006B58DF /* Sound.swift in Sources */, AA39098E2ECA061700D87EEC /* GridItemSpacing.swift in Sources */, C6C35F432ED2B70E009642F4 /* Showcase25BoardViewModel.swift in Sources */, + 3896B9722F3BB2DD00220134 /* Mane.swift in Sources */, 4C004FAF2F17C41E00D9063E /* BundleVideoGenerator+Particle.swift in Sources */, 4CA9C6A62EC9D11600CA546B /* CustomNavigationBar.swift in Sources */, 382800D32EC0628D005F1332 /* CollectionViewModel+Package.swift in Sources */, @@ -2711,11 +2772,13 @@ 4C4733BE2F1FA388005D2376 /* PolaroidVM+Effect.swift in Sources */, 4C4733BF2F1FA388005D2376 /* PolaroidVM.swift in Sources */, 4C4733C02F1FA388005D2376 /* ClearSketchPreview.swift in Sources */, + 3896B9702F3BB2D600220134 /* Saddle.swift in Sources */, 4C4733C12F1FA388005D2376 /* ClearSketchVM+Drawing.swift in Sources */, 38DE8C452F38FAA700C87924 /* BundleViewModel+Sort.swift in Sources */, 4C4733C22F1FA388005D2376 /* PixelVM+Effect.swift in Sources */, 4C4733C32F1FA388005D2376 /* CropModels.swift in Sources */, 4C4733C42F1FA388005D2376 /* SpeechBubbleVM+Effect.swift in Sources */, + 3896B9672F3B99CB00220134 /* WishHorse26VM+Effect.swift in Sources */, 4C4733C52F1FA388005D2376 /* ClearSketchCropView.swift in Sources */, 4C4733C62F1FA388005D2376 /* AudioRecorderManager.swift in Sources */, 4C4733C82F1FA388005D2376 /* KeyringInfoInputView.swift in Sources */, @@ -2739,6 +2802,7 @@ 4C004FA52F177C4600D9063E /* KeyringVideoGenerator+Rendering.swift in Sources */, 4C004FA62F177C4600D9063E /* KeyringVideoGenerator.swift in Sources */, 4C004FA72F177C4600D9063E /* BundleVideoGenerator.swift in Sources */, + 3896B9632F3B970100220134 /* WishHorse26Preview.swift in Sources */, 4C004FA82F177C4600D9063E /* KeyringVideoGenerator+Sound.swift in Sources */, 4C004FA92F177C4600D9063E /* KeyringVideoGenerator+Particle.swift in Sources */, C67B755F2ECD526A00D6E3FA /* Frame.swift in Sources */, @@ -2777,6 +2841,7 @@ 4C25260C2F3B27B3003CC5AD /* KeyringSceneView.swift in Sources */, 4C4733282F1FA2AB005D2376 /* WorkshopTemplatesView.swift in Sources */, 4C47332D2F1FA2AB005D2376 /* WorkshopViewModel.swift in Sources */, + 3896B96B2F3BA16200220134 /* WishHorse26FramePreviewView.swift in Sources */, 4C86A6142F25C0BA0023AA2D /* WorkshopKeyringGridView.swift in Sources */, 386B17642ECD142600CCCC23 /* String+Extension.swift in Sources */, 4CEBB1652EFBA54200CF53E2 /* RootViewModel.swift in Sources */, @@ -2790,6 +2855,7 @@ 4C004FB32F187ED000D9063E /* KeyringVideoGenerator+Keyring.swift in Sources */, 38F832CF2EC914C900D3A248 /* InvenExpandPopup.swift in Sources */, 4C3687FA2EBFC0FB00C64E75 /* NotificationManager.swift in Sources */, + 3896B9652F3B974500220134 /* WishHorse26VM.swift in Sources */, 38F832CB2EC9067300D3A248 /* WidgetOnboardingStepView.swift in Sources */, 4CEC62752EAE08DF0099ECEE /* HomeView.swift in Sources */, 4C6622482EAF9B3A001760B5 /* Haptic.swift in Sources */, @@ -2822,6 +2888,7 @@ 38C3C2962EC20B1E003C5DE1 /* PackagedKeyringView.swift in Sources */, 38173D0A2EB8AD7900E36F7E /* CategoryTabBarWithLongPress.swift in Sources */, 4CEBB1552EFACFA900CF53E2 /* HomeTab.swift in Sources */, + 3896B96D2F3BA1A700220134 /* WishHorse26FrameSelectorView.swift in Sources */, 4CEBB1572EFACFA900CF53E2 /* CollectionTab.swift in Sources */, 4C25262B2F3B97D6003CC5AD /* UIViewController+Find.swift in Sources */, 4C25262C2F3B97D6003CC5AD /* TabBarManager.swift in Sources */, @@ -2871,6 +2938,7 @@ 4CEC62332EAE08DA0099ECEE /* BundleGridItem.swift in Sources */, AA69DD242F14C56F00C0A41C /* BundleViewModel+CRUD.swift in Sources */, 8LLA4688ZY3G5NGU9R4ET5A6 /* BundleViewModel+Types.swift in Sources */, + 3896B9742F3BB4D500220134 /* WishHorse26VM+ImageConversion.swift in Sources */, 324NSC72E34Q6L9EW2AV1KL9 /* BundleViewModel+Fetch.swift in Sources */, WM2NO6DHYPIHK7087JF53TFU /* BundleViewModel+Purchase.swift in Sources */, X4JAMTO3227H7FD5X157LHSW /* BundleViewModel+Helpers.swift in Sources */, diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift index fa8dfda1..0eda2376 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift @@ -27,7 +27,8 @@ enum KeyringScale { "AcrylicPhoto": CGSize(width: 360, height: 360), "ClearSketch": CGSize(width: 210, height: 210), "PixelKeyring": CGSize(width: 277, height: 257), - "SpeechBubble": CGSize(width: 360, height: 249) + "SpeechBubble": CGSize(width: 360, height: 249), + "WishHorse26": CGSize(width: 269, height: 269) ] // MARK: - 템플릿 × 화면별 zoomScale @@ -36,7 +37,8 @@ enum KeyringScale { "AcrylicPhoto": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], "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] + "SpeechBubble": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.9], + "WishHorse26": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.85] ] // MARK: - 카라비너별 뭉치 키링 스케일 diff --git a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift index 250545d4..1bb42b79 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift @@ -57,6 +57,12 @@ enum WorkshopRoute: Hashable, BundleRoute { case speechBubbleCustomizing case speechBubbleInfoInput case speechBubbleComplete + + // MARK: - 2026을 말해봐 키링 템플릿 + case wishHorse26Preview + case wishHorse26Customizing + case wishHorse26InfoInput + case wishHorse26Complete // MARK: - 선물 포장 완료 case packageComplete(keyringDocumentId: String, postOfficeId: String, templateId: String, shareLink: String) @@ -77,6 +83,8 @@ enum WorkshopRoute: Hashable, BundleRoute { return .pixelPreview case "SpeechBubble": return .speechBubblePreview + case "WishHorse26": + return .wishHorse26Preview default: return nil } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Models/Mane.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Models/Mane.swift new file mode 100644 index 00000000..376146d4 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Models/Mane.swift @@ -0,0 +1,49 @@ +// +// Mane.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import Foundation +import FirebaseFirestore + +struct Mane: Identifiable, Codable, Hashable { + @DocumentID var id: String? + var imageURL: String + var color: String + var order: Int? // 정렬 순서 + + enum CodingKeys: String, CodingKey { + case id + case imageURL + case color + case order + } +} + +enum ManeColorType: String, CaseIterable { + case gray = "#4D4D4D" + case darkRed = "#810A15" + case red = "#FF383C" + case yellow = "#FDF1BC" + case pink = "#FFBAE7" + case purple = "#C2BCFE" + + /// SwiftUI Color 반환 + var color: Color { + Color(hex: self.rawValue) + } + + /// 모든 프리셋 색상 배열 + static var allColors: [Color] { + allCases.map { $0.color } + } + + /// hex 코드로 ManeColorType 찾기 + static func from(hex: String) -> ManeColorType? { + let normalizedHex = hex.uppercased() + return allCases.first { $0.rawValue.uppercased() == normalizedHex } + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Models/Saddle.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Models/Saddle.swift new file mode 100644 index 00000000..0fd3ff45 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Models/Saddle.swift @@ -0,0 +1,23 @@ +// +// Saddle.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import Foundation +import FirebaseFirestore + +struct Saddle: Identifiable, Codable, Hashable { + @DocumentID var id: String? + var imageURL: String + var thumbnailURL: String + var order: Int? // 정렬 순서 + + enum CodingKeys: String, CodingKey { + case id + case imageURL + case thumbnailURL + case order + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Effect.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Effect.swift new file mode 100644 index 00000000..670f0c81 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Effect.swift @@ -0,0 +1,196 @@ +// +// WishHorse26VM+Effect.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import Combine + +extension WishHorse26VM { + + // 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/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift new file mode 100644 index 00000000..ce52e683 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift @@ -0,0 +1,154 @@ +// +// WishHorse26VM+Firebase.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import FirebaseFirestore + +extension WishHorse26VM { + // MARK: - Firebase Template 가져오기 + func fetchTemplate() async { + isLoadingTemplate = true + defer { isLoadingTemplate = false } + + do { + let document = try await Firestore.firestore() + .collection("Template") + .document("WishHorse26") + .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("WishHorse26") + .collection("Frames") + .getDocuments() + + availableFrames = try framesSnapshot.documents.compactMap { + try $0.data(as: Frame.self) + } + + // 첫 번째 프레임을 기본 선택 + if let firstFrame = availableFrames.first { + selectedFrame = firstFrame + } + + } catch { + errorMessage = "프레임 목록을 불러오는데 실패했습니다." + } + } + + // MARK: - Firebase Saddles 가져오기 + func fetchSaddles() async { + do { + let saddlesSnapshot = try await Firestore.firestore() + .collection("Template") + .document("WishHorse26") + .collection("Saddles") + .getDocuments() + + availableSaddles = try saddlesSnapshot.documents.compactMap { + try $0.data(as: Saddle.self) + } + + // 첫 번째 안장을 기본 선택 + if let firstSaddle = availableSaddles.first { + selectedSaddle = firstSaddle + } + + } catch { + errorMessage = "안장 목록을 불러오는데 실패했습니다." + } + } + + // MARK: - Firebase Manes 가져오기 + func fetchManes() async { + do { + let manesSnapshot = try await Firestore.firestore() + .collection("Template") + .document("WishHorse26") + .collection("Manes") + .getDocuments() + + availableManes = try manesSnapshot.documents.compactMap { + try $0.data(as: Mane.self) + } + + // 첫 번째 갈기를 기본 선택 + if let firstMane = availableManes.first { + selectedMane = firstMane + } + + } catch { + errorMessage = "갈기 목록을 불러오는데 실패했습니다." + } + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+ImageConversion.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+ImageConversion.swift new file mode 100644 index 00000000..f72fac25 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+ImageConversion.swift @@ -0,0 +1,102 @@ +// +// WishHorse26VM+ImageConversion.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import Nuke + +extension WishHorse26VM { + + // MARK: - Horse Composition + + /// 선택한 프레임, 안장, 갈기를 합성하여 bodyImage로 저장 + func composeHorse() async { + guard let frame = selectedFrame, + let frameURL = URL(string: frame.frameURL) else { + return + } + + // 합성 시작 + await MainActor.run { + isComposingHorse = true + } + + defer { + Task { @MainActor in + isComposingHorse = false + } + } + + // 프레임 이미지 다운로드 + guard let frameImage = await downloadImage(from: frameURL) else { + return + } + + // 안장 이미지 다운로드 (선택된 경우) + var saddleImage: UIImage? = nil + if let saddle = selectedSaddle, + let saddleURL = URL(string: saddle.imageURL) { + saddleImage = await downloadImage(from: saddleURL) + } + + // 갈기 이미지 다운로드 (선택된 경우) + var maneImage: UIImage? = nil + if let mane = selectedMane, + let maneURL = URL(string: mane.imageURL) { + maneImage = await downloadImage(from: maneURL) + } + + // 프레임 크기 설정 (WishHorse26FramePreviewView와 동일) + let targetFrameHeight: CGFloat = 324 + let frameAspect = frameImage.size.width / frameImage.size.height + let targetFrameWidth = targetFrameHeight * frameAspect + let targetFrameSize = CGSize(width: targetFrameWidth, height: targetFrameHeight) + + let renderer = UIGraphicsImageRenderer(size: targetFrameSize) + + let composedImage = renderer.image { context in + // 1. 프레임 이미지 그리기 (배경) + frameImage.draw(in: CGRect(origin: .zero, size: targetFrameSize)) + + // 2. 갈기 이미지 그리기 (중간 레이어) + if let maneImage = maneImage { + maneImage.draw(in: CGRect(origin: .zero, size: targetFrameSize)) + } + + // 3. 안장 이미지 그리기 (최상위 레이어) + if let saddleImage = saddleImage { + saddleImage.draw(in: CGRect(origin: .zero, size: targetFrameSize)) + } + } + + bodyImage = composedImage + } + + // MARK: - Helper: Download Frame Image + + /// Nuke를 사용하여 프레임 이미지 다운로드 + 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 frame image: \(error)") + continuation.resume(returning: nil) + } + } + } + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift new file mode 100644 index 00000000..03e8ea8d --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift @@ -0,0 +1,168 @@ +// +// WishHorse26VM.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import Combine +import FirebaseFirestore + +@Observable +class WishHorse26VM: 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: - Saddle Data + var availableSaddles: [Saddle] = [] + var selectedSaddle: Saddle? = nil + + // MARK: - Mane Data + var availableManes: [Mane] = [] + var selectedMane: Mane? = nil + + var selectedManeColor: Color { + guard let mane = selectedMane else { + return ManeColorType.gray.color + } + return Color(hex: mane.color) + } + + // MARK: - Body Image + var bodyImage: UIImage? = nil + var hookOffsetY: CGFloat = 0.0 + var isComposingHorse: Bool = false + var isComposing: Bool { isComposingHorse } + + // 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 ?? "WishHorse26" } + var chainLength: Int { template?.chainLength ?? 3 } + + // MARK: - Customizing Modes + var availableCustomizingModes: [CustomizingMode] { [.frame, .effect] } + + // MARK: - 초기화 + init(userManager: UserManager = UserManager.shared) { + self.userManager = userManager + } + + // 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(WishHorse26FramePreviewView(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(WishHorse26FrameSelectorView(viewModel: self)) + default: + return AnyView(EmptyView()) + } + } + + func bottomViewHeightRatio(for mode: CustomizingMode) -> CGFloat { + switch mode { + case .frame: + return 0.4 // 프레임 모드는 더 낮은 높이 + 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 composeHorse() + } + } + } + + /// 다음 화면으로 이동하기 전 말 합성 + func beforeNavigateToNext() { + Task { + await composeHorse() + } + } + + + // MARK: - Reset + func resetCustomizingData() { + selectedSound = nil + selectedParticle = nil + customSoundURL = nil + soundId = "none" + particleId = "none" + downloadingItemIds.removeAll() + downloadProgress.removeAll() + selectedFrame = nil + selectedSaddle = nil + selectedMane = nil + bodyImage = nil + availableFrames.removeAll() + availableSaddles.removeAll() + availableManes.removeAll() + isComposingHorse = false + } + + func resetInfoData() { + nameText = "" + memoText = "" + selectedTags = [] + } + + func resetAll() { + resetCustomizingData() + resetInfoData() + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift new file mode 100644 index 00000000..a74c1193 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift @@ -0,0 +1,124 @@ +// +// WishHorse26FramePreviewView.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import NukeUI + +struct WishHorse26FramePreviewView: View { + @Bindable var viewModel: WishHorse26VM + let onSceneReady: () -> Void + + @State private var isFrameLoaded: Bool = false + + // 크기 설정 + private let targetFrameHeight: CGFloat = 269 + + var body: some View { + GeometryReader { geometry in + ZStack { + // 메인 콘텐츠 + VStack { + ZStack(alignment: .top) { + // 프레임 + 안장 + 갈기 합성 영역 + VStack { + Spacer() + .frame(height: 95) + + compositionView + } + + // frameChain 이미지 (위에 겹침) + Image(.frameChain) + .resizable() + .scaledToFit() + .frame(width: 90) + .offset(y: -31) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 168) + .opacity(isFrameLoaded ? 1 : 0) + + // 로딩 중일 때 + if !isFrameLoaded { + LoadingAlert(type: .short40, message: nil) + } + } + } + .dismissKeyboardOnTap() + .onAppear { + // 일반 SwiftUI View는 즉시 준비 완료 + onSceneReady() + } + .onChange(of: viewModel.selectedFrame) { _, _ in + Task { + await viewModel.composeHorse() + } + } + .onChange(of: viewModel.selectedSaddle) { _, _ in + Task { + await viewModel.composeHorse() + } + } + .onChange(of: viewModel.selectedMane) { _, _ in + Task { + await viewModel.composeHorse() + } + } + } + + @ViewBuilder + private var compositionView: some View { + ZStack(alignment: .center) { + if let frame = viewModel.selectedFrame { + LazyImage(url: URL(string: frame.frameURL)) { state in + if let image = state.image { + ZStack(alignment: .center) { + // 1. 프레임 이미지 (원본 크기) + image + .resizable() + .scaledToFit() + .frame(height: targetFrameHeight) + + // 2. 갈기 이미지 + if let mane = viewModel.selectedMane { + LazyImage(url: URL(string: mane.imageURL)) { maneState in + if let maneImage = maneState.image { + maneImage + .resizable() + .scaledToFit() + .frame(height: targetFrameHeight) + } + } + } + + // 2. 안장 이미지 + if let saddle = viewModel.selectedSaddle { + LazyImage(url: URL(string: saddle.imageURL)) { saddleState in + if let saddleImage = saddleState.image { + saddleImage + .resizable() + .scaledToFit() + .frame(height: targetFrameHeight) + } + } + } + } + .onAppear { + isFrameLoaded = true + } + } + } + .onDisappear { + isFrameLoaded = false + } + } + } + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift new file mode 100644 index 00000000..44282528 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift @@ -0,0 +1,227 @@ +// +// WishHorse26FrameSelectorView.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import NukeUI + +struct WishHorse26FrameSelectorView: View { + @Bindable var viewModel: WishHorse26VM + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // 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: 80) + + // MARK: - 안장 섹션 (saddle) + Text("안장") + .typography(.suit16B) + .foregroundStyle(.black100) + .padding(.leading, 20) + .padding(.bottom, 8) + .padding(.top, 13) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(viewModel.availableSaddles) { saddle in + saddleCell(saddle: saddle) + } + } + .padding(.horizontal, 20) + } + .frame(height: 60) + + // MARK: - 컬러 섹션 + Text("갈기") + .typography(.suit16B) + .foregroundStyle(.black100) + .padding(.leading, 20) + .padding(.top, 13) + + maneColorPalette + .padding(.leading, 20) + + 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 + Task { + await viewModel.composeHorse() + } + } 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: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray100) + .frame(width: 80, height: 80) + } + } + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + isSelected ? Color.main500 : Color.clear, + lineWidth: 2.5 + ) + ) + } + } + .buttonStyle(PlainButtonStyle()) + } + + // MARK: - Saddle Cell + + @ViewBuilder + private func saddleCell(saddle: Saddle) -> some View { + let isSelected = viewModel.selectedSaddle?.id == saddle.id + + Button { + viewModel.selectedSaddle = saddle + Task { + await viewModel.composeHorse() + } + } label: { + VStack(spacing: 6) { + LazyImage(url: URL(string: saddle.thumbnailURL)) { state in + if let image = state.image { + ZStack { + Color.gray50 + + image + .resizable() + .scaledToFit() + .padding(.vertical, 8) + } + .frame(width: 80, height: 59) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray100) + .frame(width: 80, height: 59) + } + } + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + isSelected ? Color.main500 : Color.clear, + lineWidth: 2.5 + ) + ) + } + } + .buttonStyle(PlainButtonStyle()) + } + + // MARK: - Mane Color Palette + + /// 갈기 컬러 팔레트 + @ViewBuilder + private var maneColorPalette: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 11) { + // 프리셋 색상들 + ForEach(Array(ManeColorType.allCases.enumerated()), id: \.offset) { index, colorType in + let color = colorType.color + + Button { + // Firebase에서 정확히 일치하는 색상의 Mane 찾기 + if let matchingMane = viewModel.availableManes.first(where: { mane in + mane.color.uppercased() == colorType.rawValue.uppercased() + }) { + viewModel.selectedMane = matchingMane + Task { + await viewModel.composeHorse() + } + } + } label: { + Circle() + .fill(color) + .frame(width: 37, height: 37) + .overlay( + Circle() + .strokeBorder(Color.black20, lineWidth: color == .white ? 1 : 0) + ) + .overlay( + ZStack { + Circle() + .strokeBorder( + Color.white, + lineWidth: isColorSelected(colorType) ? 3 : 0 + ) + + Image(.checkMarkWhite) + .resizable() + .frame(width: 12, height: 12) + .opacity(isColorSelected(colorType) ? 1 : 0) + } + + ) + .shadow( + color: isColorSelected(colorType) ? Color.black.opacity(0.5) : Color.clear, + radius: 2 + ) + } + } + } + .padding(.vertical, 12) + .padding(.horizontal, 4) + } + } + + // MARK: - Helper + /// 현재 선택된 색상인지 확인 - selectedMane 기반 + private func isColorSelected(_ colorType: ManeColorType) -> Bool { + guard let selectedMane = viewModel.selectedMane else { + return false + } + return selectedMane.color.uppercased() == colorType.rawValue.uppercased() + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26Preview.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26Preview.swift new file mode 100644 index 00000000..3697d2f3 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26Preview.swift @@ -0,0 +1,31 @@ +// +// WishHorse26Preview.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI + +struct WishHorse26Preview: View { + @Bindable var router: NavigationRouter + @State var viewModel: WishHorse26VM + @Environment(UserManager.self) private var userManager + + var body: some View { + TemplatePreviewBody( + template: viewModel.template, + fetchTemplate: { + await viewModel.fetchTemplate() + await viewModel.fetchFrames() + await viewModel.fetchSaddles() + await viewModel.fetchManes() + }, + onMake: { + router.push(.wishHorse26Customizing) + }, + router: router + ) + .swipeBackGesture(enabled: true) + } +} diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index a9a3d80b..b2eee396 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -17,6 +17,7 @@ struct WorkshopTab: View { @State private var clearSketchVM: ClearSketchVM? @State private var pixelKeyringVM: PixelVM? @State private var speechBubbleVM: SpeechBubbleVM? + @State private var wishHorse26VM: WishHorse26VM? @State private var workshopViewModel = WorkshopViewModel(userManager: UserManager.shared) var body: some View { @@ -185,6 +186,28 @@ struct WorkshopTab: View { viewModel: getSpeechBubbleVM(), navigationTitle: "키링이 완성되었어요!" ) + + // MARK: - WishHorse26 + case .wishHorse26Preview: + WishHorse26Preview(router: router, viewModel: getWishHorse26VM()) + case .wishHorse26Customizing: + KeyringCustomizingView( + router: router, + viewModel: getWishHorse26VM(), + nextRoute: .wishHorse26InfoInput + ) + case .wishHorse26InfoInput: + KeyringInfoInputView( + router: router, + viewModel: getWishHorse26VM(), + nextRoute: .wishHorse26Complete + ) + case .wishHorse26Complete: + KeyringCompleteView( + router: router, + viewModel: getWishHorse26VM(), + navigationTitle: "키링이 완성되었어요!" + ) // MARK: - 선물 포장 완료 case .packageComplete(let keyringDocumentId, let postOfficeId, let templateId, let shareLink): @@ -259,6 +282,15 @@ struct WorkshopTab: View { } return viewModel } + + private func getWishHorse26VM() -> WishHorse26VM { + guard let viewModel = wishHorse26VM else { + let newViewModel = WishHorse26VM() + wishHorse26VM = newViewModel + return newViewModel + } + return viewModel + } // MARK: - ViewModel by TemplateId private func getViewModelForTemplate(_ templateId: String) -> any KeyringViewModelProtocol {