From e6c1d3f2ac1d18071498b7f2142d6610170b1969 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 02:13:28 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EB=A7=90=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EA=B8=B0=EB=B0=98=20=ED=8C=8C=EC=9D=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EA=B8=B0=EB=B0=98=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 40 ++++ .../ViewModels/WishHorse26VM+Effect.swift | 196 ++++++++++++++++++ .../ViewModels/WishHorse26VM+Firebase.swift | 108 ++++++++++ .../ViewModels/WishHorse26VM.swift | 135 ++++++++++++ .../Views/WishHorse26Preview.swift | 26 +++ 5 files changed, 505 insertions(+) create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Effect.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26Preview.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 7135d896..d003b87d 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -47,6 +47,10 @@ 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 */; }; 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 */; }; @@ -515,6 +519,10 @@ 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 = ""; }; 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 = ""; }; @@ -1037,6 +1045,33 @@ path = Package; sourceTree = ""; }; + 3896B95F2F3B960500220134 /* WishHorse26 */ = { + isa = PBXGroup; + children = ( + 3896B9602F3B96C000220134 /* ViewModels */, + 3896B9612F3B96D900220134 /* Views */, + ); + path = WishHorse26; + sourceTree = ""; + }; + 3896B9602F3B96C000220134 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 3896B9642F3B974500220134 /* WishHorse26VM.swift */, + 3896B9662F3B99CB00220134 /* WishHorse26VM+Effect.swift */, + 3896B9682F3B9AB800220134 /* WishHorse26VM+Firebase.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 3896B9612F3B96D900220134 /* Views */ = { + isa = PBXGroup; + children = ( + 3896B9622F3B970100220134 /* WishHorse26Preview.swift */, + ); + path = Views; + sourceTree = ""; + }; 38A596702EAFA8880003D712 /* Intro */ = { isa = PBXGroup; children = ( @@ -1547,6 +1582,7 @@ 4C47337A2F1FA388005D2376 /* Pixel */, 4C4733842F1FA388005D2376 /* Polaroid */, 4C47338F2F1FA388005D2376 /* SpeechBubble */, + 3896B95F2F3B960500220134 /* WishHorse26 */, ); path = Templates; sourceTree = ""; @@ -2584,6 +2620,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 */, @@ -2702,6 +2739,7 @@ 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 */, @@ -2725,6 +2763,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 */, @@ -2776,6 +2815,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 */, 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..3230266f --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift @@ -0,0 +1,108 @@ +// +// 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 = "프레임 목록을 불러오는데 실패했습니다." + } + } +} 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..687a8959 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift @@ -0,0 +1,135 @@ +// +// 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: - Body Image + var bodyImage: UIImage? = nil + var hookOffsetY: CGFloat = 0.0 + var isComposingText: Bool = false + var isComposing: Bool { isComposingText } + + // 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: - Customizing Modes +// var availableCustomizingModes: [CustomizingMode] { +// [.frame, .effect] +// } +// +// // 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(FramePreviewView(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(FrameSelectorView(viewModel: self)) +// default: +// return AnyView(EmptyView()) +// } +// } +// +// func bottomViewHeightRatio(for mode: CustomizingMode) -> CGFloat { +// switch mode { +// case .frame: +// return 0.3 // 프레임 모드는 더 낮은 높이 +// case .effect: +// return 0.3 // 이펙트 모드도 같은 높이 +// default: +// return 0.35 +// } +// } + + // MARK: - Reset + func resetCustomizingData() { + selectedSound = nil + selectedParticle = nil + customSoundURL = nil + soundId = "none" + particleId = "none" + downloadingItemIds.removeAll() + downloadProgress.removeAll() + selectedFrame = nil + bodyImage = nil + availableFrames.removeAll() + isComposingText = false + } + + func resetInfoData() { + nameText = "" + memoText = "" + selectedTags = [] + } + + func resetAll() { + resetCustomizingData() + resetInfoData() + } +} 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..a0f52ea9 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26Preview.swift @@ -0,0 +1,26 @@ +// +// 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() }, + onMake: { + router.push(.wishHorse26Customizing) + }, + router: router + ) + .swipeBackGesture(enabled: true) + } +} From b63d8f284251a71df70d4d931871f70962a8dbbb Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 02:13:49 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EB=A7=90=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Navigation/Routes/WorkshopRoute.swift | 8 +++++ .../Presentation/Tab/Views/WorkshopTab.swift | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+) 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/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 { From 23c076acc0c642ce7cccc5d70196b82fa4acc1f5 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 03:09:23 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=EB=A7=90=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=ED=94=84=EB=A0=88=EC=9E=84=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=8C=8C=EC=9D=BC=20=EB=B0=8F=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 8 + .../Views/WishHorse26FramePreviewView.swift | 86 +++++++++ .../Views/WishHorse26FrameSelectorView.swift | 173 ++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index d003b87d..eb97853b 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -51,6 +51,8 @@ 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 */; }; 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 */; }; @@ -523,6 +525,8 @@ 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 = ""; }; 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 = ""; }; @@ -1067,6 +1071,8 @@ 3896B9612F3B96D900220134 /* Views */ = { isa = PBXGroup; children = ( + 3896B96A2F3BA16200220134 /* WishHorse26FramePreviewView.swift */, + 3896B96C2F3BA1A700220134 /* WishHorse26FrameSelectorView.swift */, 3896B9622F3B970100220134 /* WishHorse26Preview.swift */, ); path = Views; @@ -2802,6 +2808,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 */, @@ -2848,6 +2855,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 */, 4CEBB1592EFACFA900CF53E2 /* WorkshopTab.swift in Sources */, 38F832CD2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift in Sources */, 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..6533bc84 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift @@ -0,0 +1,86 @@ +// +// WishHorse26FramePreviewView.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import NukeUI + +struct WishHorse26FramePreviewView: View { + @Bindable var viewModel: WishHorse26VM + let onSceneReady: () -> Void + + @FocusState private var isTextFieldFocused: Bool + @State private var isFrameLoaded: Bool = false + + var body: some View { + GeometryReader { geometry in + ZStack { + // 메인 콘텐츠 + VStack { + ZStack(alignment: .top) { + // 프레임 + 텍스트 영역 + VStack { + Spacer() + .frame(height: 95) // 126 → 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() + } + } + + @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() + + // 2. 텍스트 입력 필드 (중앙에 오버레이) + //textInputField + .offset(y: frame.textOffsetY ?? 0) + } + .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..ccc7047c --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift @@ -0,0 +1,173 @@ +// +// 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: 16) { + // 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: 94) + + // MARK: - 안장 섹션 (saddle) + 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: 94) + + // MARK: - 컬러 섹션 + VStack(alignment: .leading, spacing: 2) { + Text("갈기") + .typography(.suit16B) + .foregroundStyle(.black100) + .padding(.leading, 20) + + ManeColorPalette(selectedColor: $viewModel.selectedColor) + .padding(.leading, 16) + } + + 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 ? .notosans12SB : .notosans12M) + .foregroundStyle(isSelected ? .main500 : .black100) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: 70) + } + } + .buttonStyle(PlainButtonStyle()) + } +} + +// 갈기 컬러 팔레트 +struct ManeColorPalette: View { + @Binding var selectedColor: Color + + /// 프리셋 색상들 + private let presetColors: [Color] = [ + Color(hex: "#810A15"), + Color(hex: "#810A15"), + Color(hex: "#FF383C"), + Color(hex: "#FDF1BC"), + Color(hex: "#FFBAE7"), + Color(hex: "#C2BCFE") + ] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 11) { + // ColorPicker + ColorPicker("", selection: $selectedColor) + .labelsHidden() + .frame(width: 37, height: 37) + + // 프리셋 색상들 + ForEach(presetColors, id: \.self) { color in + Button { + selectedColor = color + Haptic.impact(style: .light) + } label: { + Circle() + .fill(color) + .frame(width: 37, height: 37) + .overlay( + Circle() + .strokeBorder(Color.black20, lineWidth: color == .white ? 1 : 0) + ) + .overlay( + Circle() + .strokeBorder(Color.white, lineWidth: selectedColor == color ? 3 : 0) + ) + .shadow(color: selectedColor == color ? Color.black.opacity(0.5) : Color.clear, radius: 2) + } + } + } + .padding(.vertical, 12) + .padding(.horizontal, 4) + } + } +} From b71555df6fcef89f60d34b84e7cc0c89cc7876e8 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 05:33:24 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=EB=A7=90=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=EC=9A=A9=20=ED=82=A4=EB=A7=81=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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: - 카라비너별 뭉치 키링 스케일 From df9e6e62ae55986bf28c847a6ea3199cd12ffb24 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 05:34:18 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EC=95=88=EC=9E=A5=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=88=EA=B8=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 16 ++++++ .../Templates/WishHorse26/Models/Mane.swift | 49 +++++++++++++++++++ .../Templates/WishHorse26/Models/Saddle.swift | 23 +++++++++ 3 files changed, 88 insertions(+) create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Models/Mane.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Models/Saddle.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index eb97853b..70effe1e 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ 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 */; }; 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 */; }; @@ -527,6 +529,8 @@ 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 = ""; }; 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 = ""; }; @@ -1052,6 +1056,7 @@ 3896B95F2F3B960500220134 /* WishHorse26 */ = { isa = PBXGroup; children = ( + 3896B96E2F3BB2B700220134 /* Models */, 3896B9602F3B96C000220134 /* ViewModels */, 3896B9612F3B96D900220134 /* Views */, ); @@ -1078,6 +1083,15 @@ path = Views; sourceTree = ""; }; + 3896B96E2F3BB2B700220134 /* Models */ = { + isa = PBXGroup; + children = ( + 3896B96F2F3BB2D600220134 /* Saddle.swift */, + 3896B9712F3BB2DD00220134 /* Mane.swift */, + ); + path = Models; + sourceTree = ""; + }; 38A596702EAFA8880003D712 /* Intro */ = { isa = PBXGroup; children = ( @@ -2661,6 +2675,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 */, @@ -2740,6 +2755,7 @@ 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 */, 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 + } +} From d8ab8135f38b9a1ed7d80784f469633d1932719c Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 05:34:33 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 4 + .../WishHorse26VM+ImageConversion.swift | 102 ++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+ImageConversion.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 70effe1e..f5fc3eda 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 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 */; }; @@ -531,6 +532,7 @@ 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 = ""; }; @@ -1069,6 +1071,7 @@ 3896B9642F3B974500220134 /* WishHorse26VM.swift */, 3896B9662F3B99CB00220134 /* WishHorse26VM+Effect.swift */, 3896B9682F3B9AB800220134 /* WishHorse26VM+Firebase.swift */, + 3896B9732F3BB4D500220134 /* WishHorse26VM+ImageConversion.swift */, ); path = ViewModels; sourceTree = ""; @@ -2919,6 +2922,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/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) + } + } + } + } +} From 2918312b31ce9345718a7baef60e435ebe4665d8 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 05:34:58 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=EC=95=88=EC=9E=A5=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=88=EA=B8=B0=20fetch=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModels/WishHorse26VM+Firebase.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift index 3230266f..09629ea6 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift @@ -105,4 +105,51 @@ extension WishHorse26VM { 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 + selectedColor = Color(hex: firstMane.color) + } + + } catch { + errorMessage = "갈기 목록을 불러오는데 실패했습니다." + } + } } From 60b91123e346902963d7cbbec55fad14db92eb1e Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 05:35:16 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=98=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Templates/WishHorse26/Views/WishHorse26Preview.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26Preview.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26Preview.swift index a0f52ea9..3697d2f3 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26Preview.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26Preview.swift @@ -15,7 +15,12 @@ struct WishHorse26Preview: View { var body: some View { TemplatePreviewBody( template: viewModel.template, - fetchTemplate: { await viewModel.fetchTemplate() }, + fetchTemplate: { + await viewModel.fetchTemplate() + await viewModel.fetchFrames() + await viewModel.fetchSaddles() + await viewModel.fetchManes() + }, onMake: { router.push(.wishHorse26Customizing) }, From fe1acc89eba1cd76242e2a7b3a8c2f69e63e6f86 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 05:36:02 +0900 Subject: [PATCH 09/12] =?UTF-8?q?style:=20Selector=20UI=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/WishHorse26FrameSelectorView.swift | 147 ++++++++++++------ 1 file changed, 97 insertions(+), 50 deletions(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift index ccc7047c..2f754dbd 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift @@ -12,7 +12,7 @@ struct WishHorse26FrameSelectorView: View { @Bindable var viewModel: WishHorse26VM var body: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { // MARK: - 프레임 섹션 Text("프레임") .typography(.suit16B) @@ -29,36 +29,35 @@ struct WishHorse26FrameSelectorView: View { } .padding(.horizontal, 20) } - .frame(height: 94) + .frame(height: 80) // MARK: - 안장 섹션 (saddle) Text("안장") .typography(.suit16B) .foregroundStyle(.black100) .padding(.leading, 20) - .padding(.top, 20) .padding(.bottom, 8) + .padding(.top, 13) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { - ForEach(viewModel.availableFrames) { frame in - frameCell(frame: frame) + ForEach(viewModel.availableSaddles) { saddle in + saddleCell(saddle: saddle) } } .padding(.horizontal, 20) } - .frame(height: 94) + .frame(height: 60) // MARK: - 컬러 섹션 - VStack(alignment: .leading, spacing: 2) { - Text("갈기") - .typography(.suit16B) - .foregroundStyle(.black100) - .padding(.leading, 20) - - ManeColorPalette(selectedColor: $viewModel.selectedColor) - .padding(.leading, 16) - } + Text("갈기") + .typography(.suit16B) + .foregroundStyle(.black100) + .padding(.leading, 20) + .padding(.top, 13) + + maneColorPalette + .padding(.leading, 20) Spacer() } @@ -81,6 +80,9 @@ struct WishHorse26FrameSelectorView: View { Button { viewModel.selectedFrame = frame + Task { + await viewModel.composeHorse() + } } label: { VStack(spacing: 6) { LazyImage(url: URL(string: frame.thumbnailURL)) { state in @@ -93,13 +95,13 @@ struct WishHorse26FrameSelectorView: View { .scaledToFit() .padding(.vertical, 8) } - .frame(width: 105, height: 105) + .frame(width: 80, height: 80) .clipShape(RoundedRectangle(cornerRadius: 10)) } else { RoundedRectangle(cornerRadius: 10) .fill(Color.gray100) - .frame(width: 105, height: 105) + .frame(width: 80, height: 80) } } .overlay( @@ -109,47 +111,78 @@ struct WishHorse26FrameSelectorView: View { lineWidth: 2.5 ) ) - - // 프레임 이름 - Text(frame.name) - .typography(isSelected ? .notosans12SB : .notosans12M) - .foregroundStyle(isSelected ? .main500 : .black100) - .lineLimit(1) - .truncationMode(.tail) - .frame(width: 70) } } .buttonStyle(PlainButtonStyle()) } -} + + // MARK: - Saddle Cell + + @ViewBuilder + private func saddleCell(saddle: Saddle) -> some View { + let isSelected = viewModel.selectedSaddle?.id == saddle.id -// 갈기 컬러 팔레트 -struct ManeColorPalette: View { - @Binding var selectedColor: Color + 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 - /// 프리셋 색상들 - private let presetColors: [Color] = [ - Color(hex: "#810A15"), - Color(hex: "#810A15"), - Color(hex: "#FF383C"), - Color(hex: "#FDF1BC"), - Color(hex: "#FFBAE7"), - Color(hex: "#C2BCFE") - ] + image + .resizable() + .scaledToFit() + .padding(.vertical, 8) + } + .frame(width: 80, height: 59) + .clipShape(RoundedRectangle(cornerRadius: 10)) - var body: some View { + } 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) { - // ColorPicker - ColorPicker("", selection: $selectedColor) - .labelsHidden() - .frame(width: 37, height: 37) - // 프리셋 색상들 - ForEach(presetColors, id: \.self) { color in + ForEach(Array(ManeColorType.allCases.enumerated()), id: \.offset) { index, colorType in + let color = colorType.color + Button { - selectedColor = color - Haptic.impact(style: .light) + viewModel.selectedColor = color + + // 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) @@ -159,10 +192,24 @@ struct ManeColorPalette: View { .strokeBorder(Color.black20, lineWidth: color == .white ? 1 : 0) ) .overlay( - Circle() - .strokeBorder(Color.white, lineWidth: selectedColor == color ? 3 : 0) + ZStack { + Circle() + .strokeBorder( + Color.white, + lineWidth: viewModel.selectedColor == color ? 3 : 0 + ) + + Image(.checkMarkWhite) + .resizable() + .frame(width: 12, height: 12) + .opacity(viewModel.selectedColor == color ? 1 : 0) + } + + ) + .shadow( + color: viewModel.selectedColor == color ? Color.black.opacity(0.5) : Color.clear, + radius: 2 ) - .shadow(color: selectedColor == color ? Color.black.opacity(0.5) : Color.clear, radius: 2) } } } From e0e32a24aac57711ec1b312e4c6e84b152d95946 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 05:37:27 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=EC=95=88=EC=9E=A5=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=88=EA=B8=B0=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=ED=95=A9?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModels/WishHorse26VM.swift | 118 +++++++++++------- .../Views/WishHorse26FramePreviewView.swift | 44 ++++++- 2 files changed, 111 insertions(+), 51 deletions(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift index 687a8959..69c8db97 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift @@ -31,11 +31,20 @@ class WishHorse26VM: KeyringViewModelProtocol { 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 selectedColor: Color = ManeColorType.gray.color + // MARK: - Body Image var bodyImage: UIImage? = nil var hookOffsetY: CGFloat = 0.0 - var isComposingText: Bool = false - var isComposing: Bool { isComposingText } + var isComposingHorse: Bool = false + var isComposing: Bool { isComposingHorse } // MARK: - Info Data var nameText: String = "" @@ -64,48 +73,62 @@ class WishHorse26VM: KeyringViewModelProtocol { self.userManager = userManager } -// // MARK: - Customizing Modes -// var availableCustomizingModes: [CustomizingMode] { -// [.frame, .effect] -// } -// -// // 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(FramePreviewView(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(FrameSelectorView(viewModel: self)) -// default: -// return AnyView(EmptyView()) -// } -// } -// -// func bottomViewHeightRatio(for mode: CustomizingMode) -> CGFloat { -// switch mode { -// case .frame: -// return 0.3 // 프레임 모드는 더 낮은 높이 -// case .effect: -// return 0.3 // 이펙트 모드도 같은 높이 -// default: -// return 0.35 -// } -// } + // 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() { @@ -117,9 +140,14 @@ class WishHorse26VM: KeyringViewModelProtocol { downloadingItemIds.removeAll() downloadProgress.removeAll() selectedFrame = nil + selectedSaddle = nil + selectedMane = nil + selectedColor = ManeColorType.gray.color bodyImage = nil availableFrames.removeAll() - isComposingText = false + availableSaddles.removeAll() + availableManes.removeAll() + isComposingHorse = false } func resetInfoData() { diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift index 6533bc84..792cbc43 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift @@ -12,7 +12,6 @@ struct WishHorse26FramePreviewView: View { @Bindable var viewModel: WishHorse26VM let onSceneReady: () -> Void - @FocusState private var isTextFieldFocused: Bool @State private var isFrameLoaded: Bool = false var body: some View { @@ -21,10 +20,10 @@ struct WishHorse26FramePreviewView: View { // 메인 콘텐츠 VStack { ZStack(alignment: .top) { - // 프레임 + 텍스트 영역 + // 프레임 + 안장 + 갈기 합성 영역 VStack { Spacer() - .frame(height: 95) // 126 → 95 + .frame(height: 95) compositionView } @@ -54,6 +53,21 @@ struct WishHorse26FramePreviewView: View { // 일반 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 @@ -67,10 +81,28 @@ struct WishHorse26FramePreviewView: View { image .resizable() .scaledToFit() + + // 2. 갈기 이미지 + if let mane = viewModel.selectedMane { + LazyImage(url: URL(string: mane.imageURL)) { maneState in + if let maneImage = maneState.image { + maneImage + .resizable() + .scaledToFit() + } + } + } - // 2. 텍스트 입력 필드 (중앙에 오버레이) - //textInputField - .offset(y: frame.textOffsetY ?? 0) + // 2. 안장 이미지 + if let saddle = viewModel.selectedSaddle { + LazyImage(url: URL(string: saddle.imageURL)) { saddleState in + if let saddleImage = saddleState.image { + saddleImage + .resizable() + .scaledToFit() + } + } + } } .onAppear { isFrameLoaded = true From c41192d7c606569b0dfee5af0cdd0c818b7d324d Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 05:43:13 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20=EC=83=89=EC=83=81=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=A4=91=EB=B3=B5=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModels/WishHorse26VM+Firebase.swift | 1 - .../WishHorse26/ViewModels/WishHorse26VM.swift | 9 +++++++-- .../Views/WishHorse26FrameSelectorView.swift | 17 ++++++++++++----- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift index 09629ea6..ce52e683 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+Firebase.swift @@ -145,7 +145,6 @@ extension WishHorse26VM { // 첫 번째 갈기를 기본 선택 if let firstMane = availableManes.first { selectedMane = firstMane - selectedColor = Color(hex: firstMane.color) } } catch { diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift index 69c8db97..03e8ea8d 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift @@ -38,7 +38,13 @@ class WishHorse26VM: KeyringViewModelProtocol { // MARK: - Mane Data var availableManes: [Mane] = [] var selectedMane: Mane? = nil - var selectedColor: Color = ManeColorType.gray.color + + var selectedManeColor: Color { + guard let mane = selectedMane else { + return ManeColorType.gray.color + } + return Color(hex: mane.color) + } // MARK: - Body Image var bodyImage: UIImage? = nil @@ -142,7 +148,6 @@ class WishHorse26VM: KeyringViewModelProtocol { selectedFrame = nil selectedSaddle = nil selectedMane = nil - selectedColor = ManeColorType.gray.color bodyImage = nil availableFrames.removeAll() availableSaddles.removeAll() diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift index 2f754dbd..44282528 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FrameSelectorView.swift @@ -172,8 +172,6 @@ struct WishHorse26FrameSelectorView: View { let color = colorType.color Button { - viewModel.selectedColor = color - // Firebase에서 정확히 일치하는 색상의 Mane 찾기 if let matchingMane = viewModel.availableManes.first(where: { mane in mane.color.uppercased() == colorType.rawValue.uppercased() @@ -196,18 +194,18 @@ struct WishHorse26FrameSelectorView: View { Circle() .strokeBorder( Color.white, - lineWidth: viewModel.selectedColor == color ? 3 : 0 + lineWidth: isColorSelected(colorType) ? 3 : 0 ) Image(.checkMarkWhite) .resizable() .frame(width: 12, height: 12) - .opacity(viewModel.selectedColor == color ? 1 : 0) + .opacity(isColorSelected(colorType) ? 1 : 0) } ) .shadow( - color: viewModel.selectedColor == color ? Color.black.opacity(0.5) : Color.clear, + color: isColorSelected(colorType) ? Color.black.opacity(0.5) : Color.clear, radius: 2 ) } @@ -217,4 +215,13 @@ struct WishHorse26FrameSelectorView: View { .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() + } } From 8b6d9742283bae15fd8a3769938078ce2bd74e5f Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 05:56:04 +0900 Subject: [PATCH 12/12] =?UTF-8?q?style:=20=EB=B0=94=EB=94=94=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WishHorse26/Views/WishHorse26FramePreviewView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift index 792cbc43..a74c1193 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift @@ -13,6 +13,9 @@ struct WishHorse26FramePreviewView: View { let onSceneReady: () -> Void @State private var isFrameLoaded: Bool = false + + // 크기 설정 + private let targetFrameHeight: CGFloat = 269 var body: some View { GeometryReader { geometry in @@ -81,6 +84,7 @@ struct WishHorse26FramePreviewView: View { image .resizable() .scaledToFit() + .frame(height: targetFrameHeight) // 2. 갈기 이미지 if let mane = viewModel.selectedMane { @@ -89,6 +93,7 @@ struct WishHorse26FramePreviewView: View { maneImage .resizable() .scaledToFit() + .frame(height: targetFrameHeight) } } } @@ -100,6 +105,7 @@ struct WishHorse26FramePreviewView: View { saddleImage .resizable() .scaledToFit() + .frame(height: targetFrameHeight) } } }