From e659d45af6f08825ffc4deb15372e29de647d0dd Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 14:04:02 +0900 Subject: [PATCH 01/14] =?UTF-8?q?chore:=20=EB=91=90=EC=AB=80=EC=BF=A0=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B8=B0=EB=B0=98=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 52 +++++ .../ViewModels/DuZzonKuFramePreviewView.swift | 14 ++ .../DuZzonKu/ViewModels/DuZzonKuPreview.swift | 29 +++ .../ViewModels/DuZzonKuVM+Effect.swift | 196 ++++++++++++++++++ .../ViewModels/DuZzonKuVM+Firebase.swift | 12 ++ .../DuZzonKuVM+ImageConversion.swift | 12 ++ .../DuZzonKu/ViewModels/DuZzonKuVM.swift | 168 +++++++++++++++ .../Views/DuZzonKuFrameSelectorView.swift | 14 ++ 8 files changed, 497 insertions(+) create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuPreview.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Effect.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift create mode 100644 Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 258a72e6..0ca86a1d 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -42,6 +42,13 @@ 386B17522ECCDFBA00CCCC23 /* KeyringCollectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386B17512ECCDFBA00CCCC23 /* KeyringCollectView.swift */; }; 386B17542ECCE8EC00CCCC23 /* CollectionViewModel+Distribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386B17532ECCE8EC00CCCC23 /* CollectionViewModel+Distribution.swift */; }; 386B17642ECD142600CCCC23 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386B17632ECD142600CCCC23 /* String+Extension.swift */; }; + 38818DB22F3C43CF00B0A49C /* DuZzonKuVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB12F3C43CF00B0A49C /* DuZzonKuVM.swift */; }; + 38818DB42F3C43DC00B0A49C /* DuZzonKuPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB32F3C43DC00B0A49C /* DuZzonKuPreview.swift */; }; + 38818DB62F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB52F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift */; }; + 38818DB82F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB72F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift */; }; + 38818DBA2F3C443B00B0A49C /* DuZzonKuVM+Effect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB92F3C443B00B0A49C /* DuZzonKuVM+Effect.swift */; }; + 38818DBC2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DBB2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift */; }; + 38818DBE2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DBD2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift */; }; 388E72942EF341F200AE1F1B /* CollectionViewModel+UserData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388E72932EF341F200AE1F1B /* CollectionViewModel+UserData.swift */; }; 389080172ED3F05D00D7A49F /* FestivalKeyringDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389080162ED3F05D00D7A49F /* FestivalKeyringDetailView.swift */; }; 389080192ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389080182ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift */; }; @@ -521,6 +528,13 @@ 386B17512ECCDFBA00CCCC23 /* KeyringCollectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringCollectView.swift; sourceTree = ""; }; 386B17532ECCE8EC00CCCC23 /* CollectionViewModel+Distribution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionViewModel+Distribution.swift"; sourceTree = ""; }; 386B17632ECD142600CCCC23 /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; + 38818DB12F3C43CF00B0A49C /* DuZzonKuVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuZzonKuVM.swift; sourceTree = ""; }; + 38818DB32F3C43DC00B0A49C /* DuZzonKuPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DuZzonKuPreview.swift; path = Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuPreview.swift; sourceTree = SOURCE_ROOT; }; + 38818DB52F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DuZzonKuFramePreviewView.swift; path = Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift; sourceTree = SOURCE_ROOT; }; + 38818DB72F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuZzonKuFrameSelectorView.swift; sourceTree = ""; }; + 38818DB92F3C443B00B0A49C /* DuZzonKuVM+Effect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DuZzonKuVM+Effect.swift"; sourceTree = ""; }; + 38818DBB2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DuZzonKuVM+Firebase.swift"; sourceTree = ""; }; + 38818DBD2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DuZzonKuVM+ImageConversion.swift"; sourceTree = ""; }; 388E72932EF341F200AE1F1B /* CollectionViewModel+UserData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionViewModel+UserData.swift"; sourceTree = ""; }; 389080162ED3F05D00D7A49F /* FestivalKeyringDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FestivalKeyringDetailView.swift; sourceTree = ""; }; 389080182ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FestivalKeyringDetailView+Sheet.swift"; sourceTree = ""; }; @@ -1010,6 +1024,36 @@ path = Store; sourceTree = ""; }; + 38818DAE2F3C42AF00B0A49C /* DuZzonKu */ = { + isa = PBXGroup; + children = ( + 38818DB02F3C43B500B0A49C /* ViewModels */, + 38818DAF2F3C43B000B0A49C /* Views */, + ); + path = DuZzonKu; + sourceTree = ""; + }; + 38818DAF2F3C43B000B0A49C /* Views */ = { + isa = PBXGroup; + children = ( + 38818DB52F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift */, + 38818DB72F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift */, + 38818DB32F3C43DC00B0A49C /* DuZzonKuPreview.swift */, + ); + path = Views; + sourceTree = ""; + }; + 38818DB02F3C43B500B0A49C /* ViewModels */ = { + isa = PBXGroup; + children = ( + 38818DB12F3C43CF00B0A49C /* DuZzonKuVM.swift */, + 38818DB92F3C443B00B0A49C /* DuZzonKuVM+Effect.swift */, + 38818DBB2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift */, + 38818DBD2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; 388E72952EF34DA200AE1F1B /* Detail */ = { isa = PBXGroup; children = ( @@ -1620,6 +1664,7 @@ 4C4733842F1FA388005D2376 /* Polaroid */, 4C47338F2F1FA388005D2376 /* SpeechBubble */, 3896B95F2F3B960500220134 /* WishHorse26 */, + 38818DAE2F3C42AF00B0A49C /* DuZzonKu */, ); path = Templates; sourceTree = ""; @@ -2668,6 +2713,7 @@ 4CC8D0252EF11CD200317467 /* HomeViewModel.swift in Sources */, C68931CE2EB7B94B00C5F083 /* EffectManager.swift in Sources */, C6B56F202EBF72130049F969 /* ItemPurchaseManager.swift in Sources */, + 38818DBC2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift in Sources */, 38C3C28C2EC1E4B4003C5DE1 /* CollectionKeyringDetailView+Alerts.swift in Sources */, 4CAF11AB2EBF6058004CB08C /* CollectionKeyringDetailView+SaveImage.swift in Sources */, 4CA9C6A82EC9DB5300CA546B /* View+SafeAreaBottom.swift in Sources */, @@ -2680,6 +2726,7 @@ 3828F5492EC4CCE400F1B040 /* CollectionView+SearchMode.swift in Sources */, AA9B2E8B2EB001B70004D31C /* Carabiner.swift in Sources */, AA6298582EC457DF001576C0 /* CarabinerPopup.swift in Sources */, + 38818DB22F3C43CF00B0A49C /* DuZzonKuVM.swift in Sources */, AAA4467C2EC64C9900080AB1 /* SelectBackgroundSheet.swift in Sources */, 38C3C2842EC0D081003C5DE1 /* LinkCopiedPopup.swift in Sources */, C6C35F3E2ED2AB71009642F4 /* ZoomableScrollView.swift in Sources */, @@ -2697,6 +2744,7 @@ 4CA9C6A62EC9D11600CA546B /* CustomNavigationBar.swift in Sources */, 382800D32EC0628D005F1332 /* CollectionViewModel+Package.swift in Sources */, 38C3C28E2EC1F56B003C5DE1 /* CollectionKeyringDetailView+Sheet.swift in Sources */, + 38818DBA2F3C443B00B0A49C /* DuZzonKuVM+Effect.swift in Sources */, 4C6530462EBA80DA000F8154 /* PurchaseFailAlert.swift in Sources */, C6B56F602EC08BCF0049F969 /* WidgetKeyring.swift in Sources */, 4CEC622A2EAE08DA0099ECEE /* Font+Custom.swift in Sources */, @@ -2770,6 +2818,7 @@ 4C4733BC2F1FA388005D2376 /* PixelVM+ImageConversion.swift in Sources */, 4C4733BD2F1FA388005D2376 /* AcrylicPhotoGuiding.swift in Sources */, 4C4733BE2F1FA388005D2376 /* PolaroidVM+Effect.swift in Sources */, + 38818DB42F3C43DC00B0A49C /* DuZzonKuPreview.swift in Sources */, 4C4733BF2F1FA388005D2376 /* PolaroidVM.swift in Sources */, 4C4733C02F1FA388005D2376 /* ClearSketchPreview.swift in Sources */, 3896B9702F3BB2D600220134 /* Saddle.swift in Sources */, @@ -2830,6 +2879,7 @@ 4C4733232F1FA2AB005D2376 /* WorkshopView.swift in Sources */, 4C4733272F1FA2AB005D2376 /* WorkshopBundleBanner.swift in Sources */, 4C2525FB2F3B27B3003CC5AD /* MultiKeyringScene.swift in Sources */, + 38818DB62F3C43F000B0A49C /* DuZzonKuFramePreviewView.swift in Sources */, 4C2525FC2F3B27B3003CC5AD /* MultiKeyringSceneView.swift in Sources */, 4C2525FF2F3B27B3003CC5AD /* MultiKeyringCaptureScene+Capture.swift in Sources */, 4C2526002F3B27B3003CC5AD /* MultiKeyringCaptureScene.swift in Sources */, @@ -2897,6 +2947,7 @@ 38F832CD2EC90DEF00D3A248 /* WidgetOnboardingStepView+Helpers.swift in Sources */, 38A22A9D2EC27AC400B4C7C5 /* PackagedKeyringView+SaveImage.swift in Sources */, 3861024A2F1129FA0045C529 /* KeyringCacheManager.swift in Sources */, + 38818DBE2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift in Sources */, AABA4DAC2ED2D4C700A7D062 /* cardPagerView.swift in Sources */, AA9115082EB126A60026E9BC /* CarabinerCell.swift in Sources */, 38283A832EBF554E00BE45A5 /* KeyringReceiveView.swift in Sources */, @@ -2915,6 +2966,7 @@ AA91150A2EB1B7930026E9BC /* AddKeyringButton.swift in Sources */, C645AEA32EB1B8FC004BFE69 /* DataInitializer.swift in Sources */, 4CA9C6F72ECBA45200CA546B /* KeychyNotification.swift in Sources */, + 38818DB82F3C442B00B0A49C /* DuZzonKuFrameSelectorView.swift in Sources */, 4CF2A9682F0B91F300BA9FDA /* AnimatedGIFView.swift in Sources */, 4C86A61D2F29E1FE0023AA2D /* PurchaseHistoryVIewModel.swift in Sources */, 4CC8D01F2EF0447100317467 /* ChangeNameViewModel.swift in Sources */, diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift new file mode 100644 index 00000000..3a104540 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift @@ -0,0 +1,14 @@ +// +// DuZzonKuFramePreviewView.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI + +struct DuZzonKuFramePreviewView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuPreview.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuPreview.swift new file mode 100644 index 00000000..dbe4dfc3 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuPreview.swift @@ -0,0 +1,29 @@ +// +// DuZzonKuPreview.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI + +struct DuZzonKuPreview: View { + @Bindable var router: NavigationRouter + @State var viewModel: DuZzonKuVM + @Environment(UserManager.self) private var userManager + + var body: some View { + TemplatePreviewBody( + template: viewModel.template, + fetchTemplate: { + await viewModel.fetchTemplate() + await viewModel.fetchFrames() + }, + onMake: { + router.push(.duZzonKuCustomizing) + }, + router: router + ) + .swipeBackGesture(enabled: true) + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Effect.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Effect.swift new file mode 100644 index 00000000..a84b3d9b --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Effect.swift @@ -0,0 +1,196 @@ +// +// DuZzonKuVM+Effect.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import Combine + +extension DuZzonKuVM { + + // MARK: - Sorted Lists + + var sortedAvailableSounds: [Sound] { + availableSounds.sorted { sound1, sound2 in + guard let id1 = sound1.id, let id2 = sound2.id else { return false } + + let downloaded1 = isInBundle(soundId: id1) || isInCache(soundId: id1) + let downloaded2 = isInBundle(soundId: id2) || isInCache(soundId: id2) + + let priority1 = getSortPriority( + isFree: sound1.isFree, + isDownloaded: downloaded1 + ) + let priority2 = getSortPriority( + isFree: sound2.isFree, + isDownloaded: downloaded2 + ) + + return priority1 < priority2 + } + } + + var sortedAvailableParticles: [Particle] { + availableParticles.sorted { particle1, particle2 in + guard let id1 = particle1.id, let id2 = particle2.id else { return false } + + let downloaded1 = isInBundle(particleId: id1) || isInCache(particleId: id1) + let downloaded2 = isInBundle(particleId: id2) || isInCache(particleId: id2) + + let priority1 = getSortPriority( + isFree: particle1.isFree, + isDownloaded: downloaded1 + ) + let priority2 = getSortPriority( + isFree: particle2.isFree, + isDownloaded: downloaded2 + ) + + return priority1 < priority2 + } + } + + // MARK: - Sound & Particle Update + + func updateSound(_ sound: Sound?) { + selectedSound = sound + customSoundURL = nil + + if let sound = sound, let id = sound.id { + soundId = id + } else { + soundId = "none" + } + + effectSubject.send((soundId: soundId, particleId: particleId, type: .sound)) + } + + func updateParticle(_ particle: Particle?) { + selectedParticle = particle + + if let particle = particle, let id = particle.id { + particleId = id + } else { + particleId = "none" + } + + effectSubject.send((soundId: soundId, particleId: particleId, type: .particle)) + } + + // MARK: - Custom Sound (녹음) + + var hasCustomSound: Bool { + customSoundURL != nil + } + + func applyCustomSound(_ url: URL) { + customSoundURL = url + selectedSound = nil + soundId = "custom_recording" + effectSubject.send((soundId: soundId, particleId: particleId, type: .sound)) + } + + func removeCustomSound() { + customSoundURL = nil + soundId = "none" + effectSubject.send((soundId: soundId, particleId: particleId, type: .sound)) + } + + // MARK: - Ownership Check + + func isOwned(soundId: String) -> Bool { + return EffectManager.shared.isOwned(soundId: soundId, userManager: userManager) + } + + func isOwned(particleId: String) -> Bool { + return EffectManager.shared.isOwned(particleId: particleId, userManager: userManager) + } + + func isInBundle(soundId: String) -> Bool { + return EffectManager.shared.isInBundle(soundId: soundId) + } + + func isInBundle(particleId: String) -> Bool { + return EffectManager.shared.isInBundle(particleId: particleId) + } + + func isInCache(soundId: String) -> Bool { + return EffectManager.shared.isInCache(soundId: soundId) + } + + func isInCache(particleId: String) -> Bool { + return EffectManager.shared.isInCache(particleId: particleId) + } + + // MARK: - Download + + func downloadSound(_ sound: Sound) async { + guard let soundId = sound.id else { return } + + await MainActor.run { + downloadingItemIds.insert(soundId) + downloadProgress[soundId] = 0.0 + } + + let monitorTask = Task { + while !Task.isCancelled { + await MainActor.run { + if let progress = EffectManager.shared.downloadProgress[soundId] { + downloadProgress[soundId] = progress + } + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + await EffectManager.shared.downloadSound(sound, userManager: userManager) + monitorTask.cancel() + + await MainActor.run { + downloadingItemIds.remove(soundId) + downloadProgress.removeValue(forKey: soundId) + updateSound(sound) + } + } + + func downloadParticle(_ particle: Particle) async { + guard let particleId = particle.id else { return } + + await MainActor.run { + downloadingItemIds.insert(particleId) + downloadProgress[particleId] = 0.0 + } + + let monitorTask = Task { + while !Task.isCancelled { + await MainActor.run { + if let progress = EffectManager.shared.downloadProgress[particleId] { + downloadProgress[particleId] = progress + } + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + await EffectManager.shared.downloadParticle(particle, userManager: userManager) + monitorTask.cancel() + + await MainActor.run { + downloadingItemIds.remove(particleId) + downloadProgress.removeValue(forKey: particleId) + updateParticle(particle) + } + } + + // MARK: - Sorting Helper + + private func getSortPriority(isFree: Bool, isDownloaded: Bool) -> Int { + if isFree && isDownloaded { return 1 } + if !isFree && isDownloaded { return 2 } + if isFree && !isDownloaded { return 3 } + if !isFree && !isDownloaded { return 4 } + return 99 + } +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift new file mode 100644 index 00000000..5d9b21c0 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift @@ -0,0 +1,12 @@ +// +// DuZzonKuVM+Firebase.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI + +extension DuZzonKuVM { + +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift new file mode 100644 index 00000000..5b41dc42 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift @@ -0,0 +1,12 @@ +// +// DuZzonKuVM+ImageConversion.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI + +extension DuZzonKuVM { + +} diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift new file mode 100644 index 00000000..8c51a71d --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift @@ -0,0 +1,168 @@ +// +// DuZzonKuVM.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI +import Combine +import FirebaseFirestore + +@Observable +class DuZzonKuVM: KeyringViewModelProtocol { + // MARK: - Template Data + var template: KeyringTemplate? + var isLoadingTemplate = false + + // MARK: - Effect Data + var availableSounds: [Sound] = [] + var availableParticles: [Particle] = [] + var selectedSound: Sound? = nil + var selectedParticle: Particle? = nil + var customSoundURL: URL? = nil + var downloadingItemIds: Set = [] + var downloadProgress: [String: Double] = [:] + var soundId: String = "none" + var particleId: String = "none" + let effectSubject = PassthroughSubject<(soundId: String, particleId: String, type: KeyringUpdateType), Never>() + + // MARK: - Frame Data + var availableFrames: [Frame] = [] + var selectedFrame: Frame? = nil + + // MARK: - 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/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift new file mode 100644 index 00000000..34c80f93 --- /dev/null +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift @@ -0,0 +1,14 @@ +// +// DuZzonKuFrameSelectorView.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import SwiftUI + +struct DuZzonKuFrameSelectorView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} From 083f54e6de1a4714741bf68e44dd8450d8fcf9b2 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 14:15:00 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EB=91=90=EC=AB=80=EC=BF=A0=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=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 ++ .../ViewModels/DuZzonKuVM+Firebase.swift | 96 +++++++++++++++++++ .../DuZzonKu/ViewModels/DuZzonKuVM.swift | 84 ++++++++-------- .../Presentation/Tab/Views/WorkshopTab.swift | 34 +++++++ 4 files changed, 180 insertions(+), 42 deletions(-) diff --git a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift index 1bb42b79..63a8d014 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift @@ -63,6 +63,12 @@ enum WorkshopRoute: Hashable, BundleRoute { case wishHorse26Customizing case wishHorse26InfoInput case wishHorse26Complete + + // MARK: - 두쫀쿠 키링 템플릿 + case duZzonKuPreview + case duZzonKuCustomizing + case duZzonKuInfoInput + case duZzonKuComplete // MARK: - 선물 포장 완료 case packageComplete(keyringDocumentId: String, postOfficeId: String, templateId: String, shareLink: String) @@ -85,6 +91,8 @@ enum WorkshopRoute: Hashable, BundleRoute { return .speechBubblePreview case "WishHorse26": return .wishHorse26Preview + case "DuZzonKu": + return .duZzonKuPreview default: return nil } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift index 5d9b21c0..6eceb402 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift @@ -6,7 +6,103 @@ // import SwiftUI +import FirebaseFirestore extension DuZzonKuVM { + // MARK: - Firebase Template 가져오기 + func fetchTemplate() async { + isLoadingTemplate = true + defer { isLoadingTemplate = false } + + do { + let document = try await Firestore.firestore() + .collection("Template") + .document("DuZzonKu") + .getDocument() + + template = try document.data(as: KeyringTemplate.self) + hookOffsetY = template?.hookOffsetY ?? 0.0 + + } catch { + errorMessage = "템플릿을 불러오는데 실패했습니다." + } + } + // MARK: - Firebase Effects 가져오기 + func fetchEffects() async { + guard let user = userManager.currentUser else { + errorMessage = "유저 정보를 불러올 수 없습니다." + return + } + + do { + // Sound 전체 가져오기 + let soundsSnapshot = try await Firestore.firestore() + .collection("Sound") + .whereField("isActive", isEqualTo: true) + .getDocuments() + + let allSounds = try soundsSnapshot.documents.compactMap { + try $0.data(as: Sound.self) + } + + let ownedSounds = allSounds.filter { sound in + guard let id = sound.id else { return false } + return user.soundEffects.contains(id) + } + let notOwnedSounds = allSounds.filter { sound in + guard let id = sound.id else { return false } + return !user.soundEffects.contains(id) + } + + availableSounds = ownedSounds + notOwnedSounds + + // Particle 전체 가져오기 + let particlesSnapshot = try await Firestore.firestore() + .collection("Particle") + .whereField("isActive", isEqualTo: true) + .getDocuments() + + let allParticles = try particlesSnapshot.documents.compactMap { + try $0.data(as: Particle.self) + } + + let ownedParticles = allParticles.filter { particle in + guard let id = particle.id else { return false } + return user.particleEffects.contains(id) + } + let notOwnedParticles = allParticles.filter { particle in + guard let id = particle.id else { return false } + return !user.particleEffects.contains(id) + } + + availableParticles = ownedParticles + notOwnedParticles + + } catch { + errorMessage = "이펙트 목록을 불러오는데 실패했습니다." + } + } + + // MARK: - Firebase Frames 가져오기 + func fetchFrames() async { + do { + let framesSnapshot = try await Firestore.firestore() + .collection("Template") + .document("DuZzonKu") + .collection("Frames") + .getDocuments() + + availableFrames = try framesSnapshot.documents.compactMap { + try $0.data(as: Frame.self) + } + + // 첫 번째 프레임을 기본 선택 + if let firstFrame = availableFrames.first { + selectedFrame = firstFrame + } + + } catch { + errorMessage = "프레임 목록을 불러오는데 실패했습니다." + } + } } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift index 8c51a71d..baeb3e4f 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift @@ -68,7 +68,7 @@ class DuZzonKuVM: KeyringViewModelProtocol { var errorMessage: String? // MARK: - Template Info - var templateId: String { template?.id ?? "WishHorse26" } + var templateId: String { template?.id ?? "DuZzonKu" } var chainLength: Int { template?.chainLength ?? 3 } // MARK: - Customizing Modes @@ -80,31 +80,31 @@ class DuZzonKuVM: KeyringViewModelProtocol { } // 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 sceneView(for mode: CustomizingMode, onSceneReady: @escaping () -> Void) -> AnyView { +// switch mode { +// case .effect: +// return AnyView(KeyringSceneView(viewModel: self, onSceneReady: onSceneReady)) +// case .frame: +// return AnyView(DuZzonKuFramePreviewView(viewModel: self, onSceneReady: onSceneReady)) +// default: +// return AnyView(EmptyView()) +// } +// } +// +// func bottomContentView( +// for mode: CustomizingMode, +// showPurchaseSheet: Binding, +// cartItems: Binding<[EffectItem]> +// ) -> AnyView { +// switch mode { +// case .effect: +// return AnyView(EffectSelectorView(viewModel: self, cartItems: cartItems)) +// case .frame: +// return AnyView(DuZzonKuFrameSelectorView(viewModel: self)) +// default: +// return AnyView(EmptyView()) +// } +// } func bottomViewHeightRatio(for mode: CustomizingMode) -> CGFloat { switch mode { @@ -119,22 +119,22 @@ class DuZzonKuVM: KeyringViewModelProtocol { // MARK: - Lifecycle Callbacks - /// 모드 변경 시 프레임 → 다른 모드로 전환되면 말 합성 - func onModeChanged(from oldMode: CustomizingMode, to newMode: CustomizingMode) { - if oldMode == .frame && newMode != .frame { - Task { - await composeHorse() - } - } - } - - /// 다음 화면으로 이동하기 전 말 합성 - func beforeNavigateToNext() { - Task { - await composeHorse() - } - } - +// /// 모드 변경 시 프레임 → 다른 모드로 전환되면 말 합성 +// 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() { diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index b2eee396..6320fe7c 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -18,6 +18,7 @@ struct WorkshopTab: View { @State private var pixelKeyringVM: PixelVM? @State private var speechBubbleVM: SpeechBubbleVM? @State private var wishHorse26VM: WishHorse26VM? + @State private var duZzonKuVM: DuZzonKuVM? @State private var workshopViewModel = WorkshopViewModel(userManager: UserManager.shared) var body: some View { @@ -208,6 +209,28 @@ struct WorkshopTab: View { viewModel: getWishHorse26VM(), navigationTitle: "키링이 완성되었어요!" ) + + // MARK: - DuZzonKu + case .duZzonKuPreview: + DuZzonKuPreview(router: router, viewModel: getDuZzonKuVM()) + case .duZzonKuCustomizing: + KeyringCustomizingView( + router: router, + viewModel: getDuZzonKuVM(), + nextRoute: .duZzonKuInfoInput + ) + case .duZzonKuInfoInput: + KeyringInfoInputView( + router: router, + viewModel: getDuZzonKuVM(), + nextRoute: .duZzonKuComplete + ) + case .duZzonKuComplete: + KeyringCompleteView( + router: router, + viewModel: getDuZzonKuVM(), + navigationTitle: "키링이 완성되었어요!" + ) // MARK: - 선물 포장 완료 case .packageComplete(let keyringDocumentId, let postOfficeId, let templateId, let shareLink): @@ -291,6 +314,15 @@ struct WorkshopTab: View { } return viewModel } + + private func getDuZzonKuVM() -> DuZzonKuVM { + guard let viewModel = duZzonKuVM else { + let newViewModel = DuZzonKuVM() + duZzonKuVM = newViewModel + return newViewModel + } + return viewModel + } // MARK: - ViewModel by TemplateId private func getViewModelForTemplate(_ templateId: String) -> any KeyringViewModelProtocol { @@ -305,6 +337,8 @@ struct WorkshopTab: View { return getPixelKeyringVM() case "SpeechBubble": return getSpeechBubbleVM() + case "DuZzonKu": + return getDuZzonKuVM() default: return getPolaroidVM() } From 7ba563bafaea3f46211400db99c37bc32993c1e3 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 22:35:30 +0900 Subject: [PATCH 03/14] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WishHorse26/Views/WishHorse26FramePreviewView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift index a74c1193..8e994102 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift @@ -51,7 +51,6 @@ struct WishHorse26FramePreviewView: View { } } } - .dismissKeyboardOnTap() .onAppear { // 일반 SwiftUI View는 즉시 준비 완료 onSceneReady() From f96061df58ec734219b06faff8d3b64f2f1ae7d3 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 22:36:42 +0900 Subject: [PATCH 04/14] =?UTF-8?q?chore:=20=ED=82=A4=EB=A7=81=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=BC=EA=B0=92=20=EC=84=A4=EC=A0=95?= 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 0eda2376..3d8b644f 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift @@ -28,7 +28,8 @@ enum KeyringScale { "ClearSketch": CGSize(width: 210, height: 210), "PixelKeyring": CGSize(width: 277, height: 257), "SpeechBubble": CGSize(width: 360, height: 249), - "WishHorse26": CGSize(width: 269, height: 269) + "WishHorse26": CGSize(width: 269, height: 269), + "DuZzonKu": CGSize(width: 376, height: 376) ] // MARK: - 템플릿 × 화면별 zoomScale @@ -38,7 +39,8 @@ enum KeyringScale { "ClearSketch": [.customizing: 1.0, .infoInput: 1.0, .complete: 1.0], "PixelKeyring": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.9], "SpeechBubble": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.9], - "WishHorse26": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.85] + "WishHorse26": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.85], + "DuZzonKu": [.customizing: 1.0, .infoInput: 1.0, .complete: 0.8] ] // MARK: - 카라비너별 뭉치 키링 스케일 From cabfba2bafe6f89ce7b4f08e7250bfcc932a1c39 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 23:07:32 +0900 Subject: [PATCH 05/14] =?UTF-8?q?style:=20=ED=94=84=EB=A0=88=EC=9E=84=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=B7=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/DuZzonKuFrameSelectorView.swift | 83 ++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift index 34c80f93..f55c064c 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift @@ -6,9 +6,90 @@ // import SwiftUI +import NukeUI struct DuZzonKuFrameSelectorView: View { + @Bindable var viewModel: DuZzonKuVM + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + 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: 130) + + Spacer() + } + .background( + UnevenRoundedRectangle( + topLeadingRadius: 24, + topTrailingRadius: 24 + ) + .fill(.white100) + .shadow(color: .black.opacity(0.15), radius: 9) + ) + .background(Color.gray50.ignoresSafeArea(edges: .bottom)) + } + + // MARK: - Frame Cell + + @ViewBuilder + private func frameCell(frame: Frame) -> some View { + let isSelected = viewModel.selectedFrame?.id == frame.id + + Button { + viewModel.selectedFrame = frame + } label: { + VStack(spacing: 6) { + LazyImage(url: URL(string: frame.thumbnailURL)) { state in + if let image = state.image { + ZStack { + Color.gray50 + + image + .resizable() + .scaledToFit() + .padding(.vertical, 8) + } + .frame(width: 105, height: 105) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray100) + .frame(width: 105, height: 105) + } + } + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + isSelected ? Color.main500 : Color.clear, + lineWidth: 2.5 + ) + ) + + // 프레임 이름 + Text(frame.name) + .typography(isSelected ? .notosans12SB : .notosans12M) + .foregroundStyle(isSelected ? .main500 : .black100) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: 70) + } + } + .buttonStyle(PlainButtonStyle()) } } From c4869805900e650b2358f57edccf3a396b631096 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 23:58:35 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EC=B2=B4=EC=BB=A4=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=98=81=EC=97=AD=20=EC=A0=95=EB=B3=B4=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 | 4 +++ .../Template/CheckerBoardRect.swift | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 Keychy/Keychy/CommonModels/Template/CheckerBoardRect.swift diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 0ca86a1d..77cbaf86 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 38818DBA2F3C443B00B0A49C /* DuZzonKuVM+Effect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DB92F3C443B00B0A49C /* DuZzonKuVM+Effect.swift */; }; 38818DBC2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DBB2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift */; }; 38818DBE2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DBD2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift */; }; + 38818DC02F3CBE2E00B0A49C /* CheckerBoardRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38818DBF2F3CBE2E00B0A49C /* CheckerBoardRect.swift */; }; 388E72942EF341F200AE1F1B /* CollectionViewModel+UserData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388E72932EF341F200AE1F1B /* CollectionViewModel+UserData.swift */; }; 389080172ED3F05D00D7A49F /* FestivalKeyringDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389080162ED3F05D00D7A49F /* FestivalKeyringDetailView.swift */; }; 389080192ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 389080182ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift */; }; @@ -535,6 +536,7 @@ 38818DB92F3C443B00B0A49C /* DuZzonKuVM+Effect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DuZzonKuVM+Effect.swift"; sourceTree = ""; }; 38818DBB2F3C444300B0A49C /* DuZzonKuVM+Firebase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DuZzonKuVM+Firebase.swift"; sourceTree = ""; }; 38818DBD2F3C444C00B0A49C /* DuZzonKuVM+ImageConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DuZzonKuVM+ImageConversion.swift"; sourceTree = ""; }; + 38818DBF2F3CBE2E00B0A49C /* CheckerBoardRect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckerBoardRect.swift; sourceTree = ""; }; 388E72932EF341F200AE1F1B /* CollectionViewModel+UserData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionViewModel+UserData.swift"; sourceTree = ""; }; 389080162ED3F05D00D7A49F /* FestivalKeyringDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FestivalKeyringDetailView.swift; sourceTree = ""; }; 389080182ED3F32700D7A49F /* FestivalKeyringDetailView+Sheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FestivalKeyringDetailView+Sheet.swift"; sourceTree = ""; }; @@ -2422,6 +2424,7 @@ children = ( 4CEC61DE2EAE08C00099ECEE /* KeyringTemplate.swift */, C67B755D2ECD526A00D6E3FA /* Frame.swift */, + 38818DBF2F3CBE2E00B0A49C /* CheckerBoardRect.swift */, ); path = Template; sourceTree = ""; @@ -2660,6 +2663,7 @@ 4C3687FC2EC05E6800C64E75 /* AccountAlert.swift in Sources */, 388E72942EF341F200AE1F1B /* CollectionViewModel+UserData.swift in Sources */, 4CEBB14D2EFAA52F00CF53E2 /* DeepLinkHandler.swift in Sources */, + 38818DC02F3CBE2E00B0A49C /* CheckerBoardRect.swift in Sources */, 4CEBB14E2EFAA52F00CF53E2 /* DeepLinkManager.swift in Sources */, 4CEC61E62EAE08C00099ECEE /* Keyring.swift in Sources */, 4CEC61F12EAE08C40099ECEE /* KeychyApp.swift in Sources */, diff --git a/Keychy/Keychy/CommonModels/Template/CheckerBoardRect.swift b/Keychy/Keychy/CommonModels/Template/CheckerBoardRect.swift new file mode 100644 index 00000000..2d4ad96b --- /dev/null +++ b/Keychy/Keychy/CommonModels/Template/CheckerBoardRect.swift @@ -0,0 +1,32 @@ +// +// CheckerBoardRect.swift +// Keychy +// +// Created by Jini on 2/11/26. +// + +import Foundation + +/// 체커보드의 실제 영역 정보 (프레임 기준 비율) +struct CheckerBoardRect: Codable, Identifiable, Hashable { + /// 고유 ID (Firebase 자동 생성 또는 순서) + var id: String? + + /// X 위치 (프레임 너비 기준 비율, 0.0 ~ 1.0) + var x: CGFloat + + /// Y 위치 (프레임 높이 기준 비율, 0.0 ~ 1.0) + var y: CGFloat + + /// 너비 (프레임 너비 기준 비율, 0.0 ~ 1.0) + var width: CGFloat + + /// 높이 (프레임 높이 기준 비율, 0.0 ~ 1.0) + var height: CGFloat + + /// 모서리 radius + var cornerRadius: CGFloat? + + /// 순서 (여러 개일 때 정렬용) + var order: Int? +} From 1fbf60a66b57db6a41c4805aee5bfd2529ce1d6f Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 23:59:02 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=ED=94=84=EB=A0=88=EC=9E=84=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=EC=97=90=20=EC=B2=B4=EC=BB=A4=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EA=B4=80=EB=A0=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy/CommonModels/Template/Frame.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Keychy/Keychy/CommonModels/Template/Frame.swift b/Keychy/Keychy/CommonModels/Template/Frame.swift index 77e74ed3..17302619 100644 --- a/Keychy/Keychy/CommonModels/Template/Frame.swift +++ b/Keychy/Keychy/CommonModels/Template/Frame.swift @@ -16,6 +16,8 @@ struct Frame: Identifiable, Codable, Hashable { var type: String? // SpeechBubble 프레임 타입 (A, B, C) var order: Int? // 정렬 순서 var textOffsetY: CGFloat? // SpeechBubble 텍스트 Y 오프셋 + var checkerBoardURL: String? // DuZzonKu 프레임에 맞는 체커보드 + var checkerBoardRects: [CheckerBoardRect]? enum CodingKeys: String, CodingKey { case id @@ -25,5 +27,7 @@ struct Frame: Identifiable, Codable, Hashable { case type case order case textOffsetY + case checkerBoardURL + case checkerBoardRects } } From 55a7cc2db43be7f1e6a19f450ff714cf08a75ae8 Mon Sep 17 00:00:00 2001 From: Jini Date: Wed, 11 Feb 2026 23:59:57 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=EC=82=AC=EC=A7=84=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=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 --- .../DuZzonKu/ViewModels/DuZzonKuVM.swift | 142 ++++++++++-------- 1 file changed, 79 insertions(+), 63 deletions(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift index baeb3e4f..924aa8ec 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift @@ -31,26 +31,20 @@ class DuZzonKuVM: KeyringViewModelProtocol { var availableFrames: [Frame] = [] var selectedFrame: Frame? = nil - // MARK: - Saddle Data - var availableSaddles: [Saddle] = [] - var selectedSaddle: Saddle? = nil + // MARK: - Photo Data + var selectedPhotoImage: UIImage? = nil + var photoImages: [Int: UIImage] = [:] // 인덱스별 사진 저장 - // 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: - Photo Transform State + var photoScale: CGFloat = 1.0 + var photoRotation: Angle = .zero + var photoOffset: CGSize = .zero // MARK: - Body Image var bodyImage: UIImage? = nil var hookOffsetY: CGFloat = 0.0 - var isComposingHorse: Bool = false - var isComposing: Bool { isComposingHorse } + var isComposingPhoto: Bool = false + var isComposing: Bool { isComposingPhoto } // MARK: - Info Data var nameText: String = "" @@ -79,37 +73,60 @@ class DuZzonKuVM: KeyringViewModelProtocol { self.userManager = userManager } + // MARK: - Photo Management (여러 개 지원) + /// 특정 인덱스의 사진 가져오기 + func getPhoto(at index: Int) -> UIImage? { + return photoImages[index] + } + + /// 특정 인덱스에 사진 저장 + func setPhoto(_ image: UIImage, at index: Int) { + photoImages[index] = image + // 첫 번째 사진은 selectedPhotoImage에도 저장 (하위 호환성) + if index == 0 { + selectedPhotoImage = image + } + } + + /// 특정 인덱스의 사진 제거 + func removePhoto(at index: Int) { + photoImages.removeValue(forKey: index) + if index == 0 { + selectedPhotoImage = nil + } + } + // MARK: - View Providers -// func sceneView(for mode: CustomizingMode, onSceneReady: @escaping () -> Void) -> AnyView { -// switch mode { -// case .effect: -// return AnyView(KeyringSceneView(viewModel: self, onSceneReady: onSceneReady)) -// case .frame: -// return AnyView(DuZzonKuFramePreviewView(viewModel: self, onSceneReady: onSceneReady)) -// default: -// return AnyView(EmptyView()) -// } -// } -// -// func bottomContentView( -// for mode: CustomizingMode, -// showPurchaseSheet: Binding, -// cartItems: Binding<[EffectItem]> -// ) -> AnyView { -// switch mode { -// case .effect: -// return AnyView(EffectSelectorView(viewModel: self, cartItems: cartItems)) -// case .frame: -// return AnyView(DuZzonKuFrameSelectorView(viewModel: self)) -// default: -// return AnyView(EmptyView()) -// } -// } + func sceneView(for mode: CustomizingMode, onSceneReady: @escaping () -> Void) -> AnyView { + switch mode { + case .effect: + return AnyView(KeyringSceneView(viewModel: self, onSceneReady: onSceneReady)) + case .frame: + return AnyView(DuZzonKuFramePreviewView(viewModel: self, onSceneReady: onSceneReady)) + default: + return AnyView(EmptyView()) + } + } + + func bottomContentView( + for mode: CustomizingMode, + showPurchaseSheet: Binding, + cartItems: Binding<[EffectItem]> + ) -> AnyView { + switch mode { + case .effect: + return AnyView(EffectSelectorView(viewModel: self, cartItems: cartItems)) + case .frame: + return AnyView(DuZzonKuFrameSelectorView(viewModel: self)) + default: + return AnyView(EmptyView()) + } + } func bottomViewHeightRatio(for mode: CustomizingMode) -> CGFloat { switch mode { case .frame: - return 0.4 // 프레임 모드는 더 낮은 높이 + return 0.3 // 프레임 모드는 더 낮은 높이 case .effect: return 0.3 // 이펙트 모드도 같은 높이 default: @@ -118,23 +135,21 @@ class DuZzonKuVM: KeyringViewModelProtocol { } // MARK: - Lifecycle Callbacks - -// /// 모드 변경 시 프레임 → 다른 모드로 전환되면 말 합성 -// func onModeChanged(from oldMode: CustomizingMode, to newMode: CustomizingMode) { -// if oldMode == .frame && newMode != .frame { -// Task { -// await composeHorse() -// } -// } -// } -// -// /// 다음 화면으로 이동하기 전 말 합성 -// func beforeNavigateToNext() { -// Task { -// await composeHorse() -// } -// } -// + /// 모드 변경 시 프레임 → 다른 모드로 전환되면 사진과 프레임 합성 + func onModeChanged(from oldMode: CustomizingMode, to newMode: CustomizingMode) { + if oldMode == .frame && newMode != .frame { + Task { + await composePhotoWithFrame() + } + } + } + + /// 다음 화면으로 이동하기 전 사진과 프레임 합성 + func beforeNavigateToNext() { + Task { + await composePhotoWithFrame() + } + } // MARK: - Reset func resetCustomizingData() { @@ -146,13 +161,14 @@ class DuZzonKuVM: KeyringViewModelProtocol { downloadingItemIds.removeAll() downloadProgress.removeAll() selectedFrame = nil - selectedSaddle = nil - selectedMane = nil + selectedPhotoImage = nil + photoImages.removeAll() + photoScale = 1.0 + photoRotation = .zero + photoOffset = .zero bodyImage = nil availableFrames.removeAll() - availableSaddles.removeAll() - availableManes.removeAll() - isComposingHorse = false + isComposingPhoto = false } func resetInfoData() { From 090eaf5020a95287efa6a93e5b8cc1f741a3ccbc Mon Sep 17 00:00:00 2001 From: Jini Date: Thu, 12 Feb 2026 00:00:15 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=ED=94=84=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B7=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModels/DuZzonKuFramePreviewView.swift | 368 +++++++++++++++++- 1 file changed, 367 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift index 3a104540..7f6128b1 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift @@ -6,9 +6,375 @@ // import SwiftUI +import PhotosUI +import NukeUI +import Nuke struct DuZzonKuFramePreviewView: View { + @Bindable var viewModel: DuZzonKuVM + let onSceneReady: () -> Void + + @State private var showPhotoSelectSheet = false + @State private var showPhotoPicker = false + @State private var showCamera = false + @State private var selectedPhotoItem: PhotosPickerItem? = nil + @State private var showEditButton = false + @State private var isFrameLoaded: Bool = false + + // 여러 개의 체커보드 중 어떤 것을 편집 중인지 + @State private var editingRectIndex: Int? = nil + + // 시트에서 선택한 액션을 저장 + @State private var pendingAction: PhotoAction? = nil + + enum PhotoAction { + case camera + case photoLibrary + } + + // 제스처 임시 값 + @State private var currentScale: CGFloat = 1.0 + @State private var currentRotation: Angle = .zero + @State private var currentOffset: CGSize = .zero + + // 크기 설정 + private let targetFrameHeight: CGFloat = 376 + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + 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) + } + } + } + .photosPicker( + isPresented: $showPhotoPicker, + selection: $selectedPhotoItem, + matching: .images + ) + .fullScreenCover(isPresented: $showCamera) { + CameraView { image in + // 현재 편집 중인 영역에 사진 저장 + if let index = editingRectIndex { + viewModel.setPhoto(image, at: index) + } else { + viewModel.selectedPhotoImage = image + } + + // 새 사진 선택 시 변환 초기화 + viewModel.photoScale = 1.0 + viewModel.photoRotation = .zero + viewModel.photoOffset = .zero + currentScale = 1.0 + currentRotation = .zero + currentOffset = .zero + showEditButton = false + editingRectIndex = nil + } + .ignoresSafeArea() + } + .sheet(isPresented: $showPhotoSelectSheet, onDismiss: { + // 시트가 완전히 닫힌 후 액션 실행 + if let action = pendingAction { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + switch action { + case .camera: + showCamera = true + case .photoLibrary: + showPhotoPicker = true + } + pendingAction = nil + } + } + }) { + PhotoSelectSheet( + onCameraSelected: { + pendingAction = .camera + }, + onPhotoLibrarySelected: { + pendingAction = .photoLibrary + } + ) + } + .onChange(of: selectedPhotoItem) { oldValue, newValue in + Task { + if let data = try? await newValue?.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data) { + // 현재 편집 중인 영역에 사진 저장 + if let index = editingRectIndex { + viewModel.setPhoto(uiImage, at: index) + } else { + viewModel.selectedPhotoImage = uiImage + } + + // 새 사진 선택 시 변환 초기화 + viewModel.photoScale = 1.0 + viewModel.photoRotation = .zero + viewModel.photoOffset = .zero + currentScale = 1.0 + currentRotation = .zero + currentOffset = .zero + showEditButton = false + editingRectIndex = nil + } + } + } + .onAppear { + // 일반 SwiftUI View는 즉시 준비 완료 + onSceneReady() + } + }/* width 0.46*/ + + private var photoGestures: some Gesture { + // 확대/축소 + let magnificationGesture = MagnificationGesture(minimumScaleDelta: 0.0) + .onChanged { value in + Task { @MainActor in + currentScale = value + } + } + .onEnded { value in + Task { @MainActor in + let newScale = viewModel.photoScale * value + viewModel.photoScale = min(max(newScale, 0.5), 3.0) + currentScale = 1.0 + } + } + + // 회전 + let rotationGesture = RotationGesture(minimumAngleDelta: .zero) + .onChanged { value in + Task { @MainActor in + currentRotation = value + } + } + .onEnded { value in + Task { @MainActor in + viewModel.photoRotation += value + currentRotation = .zero + } + } + + // 이동 + let dragGesture = DragGesture(minimumDistance: 10) + .onChanged { value in + Task { @MainActor in + currentOffset = CGSize( + width: value.translation.width, + height: value.translation.height + ) + } + } + .onEnded { value in + Task { @MainActor in + viewModel.photoOffset = CGSize( + width: viewModel.photoOffset.width + value.translation.width, + height: viewModel.photoOffset.height + value.translation.height + ) + currentOffset = .zero + } + } + + return magnificationGesture + .simultaneously(with: rotationGesture) + .simultaneously(with: dragGesture) + } + + private var finalScale: CGFloat { + let calculatedScale = viewModel.photoScale * currentScale + return min(max(calculatedScale, 0.5), 3.0) + } + + private var finalRotation: Angle { + viewModel.photoRotation + currentRotation + } + + private var finalOffset: CGSize { + CGSize( + width: viewModel.photoOffset.width + currentOffset.width, + height: viewModel.photoOffset.height + currentOffset.height + ) + } + + @ViewBuilder + private var compositionView: some View { + ZStack(alignment: .center) { + if let frame = viewModel.selectedFrame { + LazyImage(url: URL(string: frame.frameURL)) { state in + if state.isLoading { + LoadingAlert(type: .short40, message: nil) + .frame(height: targetFrameHeight) + } else if let image = state.image { + let frameAspect = (state.imageContainer?.image.size.width ?? 1) / (state.imageContainer?.image.size.height ?? 1) + let targetFrameWidth = targetFrameHeight * frameAspect + + ZStack(alignment: .topLeading) { + // 1. 여러 개의 체커보드/사진 영역 + if let checkerBoardRects = frame.checkerBoardRects { + ForEach(Array(checkerBoardRects.enumerated()), id: \.offset) { index, rect in + checkerBoardView( + rect: rect, + index: index, + targetFrameWidth: targetFrameWidth, + targetFrameHeight: targetFrameHeight, + checkerBoardURL: frame.checkerBoardURL + ) + } + } + + // 2. 프레임 이미지 + image + .resizable() + .scaledToFit() + .frame(height: targetFrameHeight) + .allowsHitTesting(false) + } + .frame(width: targetFrameWidth, height: targetFrameHeight) + .onAppear { + isFrameLoaded = true + } + } + } + .onDisappear { + isFrameLoaded = false + } + .onAppear { + print("checkerBoardRects:", frame.checkerBoardRects ?? []) + } + } + } + } + + @ViewBuilder + private func checkerBoardView( + rect: CheckerBoardRect, + index: Int, + targetFrameWidth: CGFloat, + targetFrameHeight: CGFloat, + checkerBoardURL: String? + ) -> some View { + // Firebase에서 정의한 체커보드 실제 영역 + let photoWidth = targetFrameWidth * rect.width + let photoHeight = targetFrameHeight * rect.height + let photoX = targetFrameWidth * rect.x + let photoY = targetFrameHeight * rect.y + + let radius = rect.cornerRadius ?? 0 + let clipShape = RoundedRectangle(cornerRadius: radius) + + ZStack { + // 체커보드 이미지 (선택사항, 디버깅용) + if let checkerBoardURLString = checkerBoardURL, + let checkerBoardURL = URL(string: checkerBoardURLString) { + LazyImage(url: checkerBoardURL) { checkerState in + if let checkerImage = checkerState.image { + checkerImage + .resizable() + .scaledToFill() + .frame(width: photoWidth, height: photoHeight) + .clipShape(clipShape) + } + } + } + + // 해당 영역에 저장된 사진 + if let photoImage = viewModel.getPhoto(at: index) { + ZStack { + Image(uiImage: photoImage) + .resizable() + .scaledToFill() + .frame(width: photoWidth, height: photoHeight) + .scaleEffect(finalScale) + .rotationEffect(finalRotation) + .offset(finalOffset) + .clipShape(clipShape) + + // 수정 버튼 표시 시 딤 처리 + if showEditButton && editingRectIndex == index { + Color.black20 + } + } + .frame(width: photoWidth, height: photoHeight) + .clipShape(clipShape) + .contentShape(clipShape) + .gesture(photoGestures) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + if editingRectIndex == index { + showEditButton.toggle() + } else { + editingRectIndex = index + showEditButton = true + } + } + } + } else { + // 사진이 없을 때 딤 오버레이 + Color.black20 + .frame(width: photoWidth, height: photoHeight) + } + + // 버튼들 + if viewModel.getPhoto(at: index) == nil { + // 플러스 버튼 + Button { + editingRectIndex = index + showPhotoSelectSheet = true + } label: { + Image(.plus) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .padding(5) + } + .glassEffect(.clear.interactive(), in: .circle) + .transition(.scale.combined(with: .opacity)) + } else if showEditButton && editingRectIndex == index { + // 연필 버튼 + Button { + showPhotoSelectSheet = true + showEditButton = false + } label: { + Image(.editPencil) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .padding(5) + } + .glassEffect(.clear.interactive(), in: .circle) + .transition(.scale.combined(with: .opacity)) + } + } + .position( + x: photoX + photoWidth / 2, + y: photoY + photoHeight / 2 + ) } } From 128e3f8a5757d40261732463a59affad83d1887c Mon Sep 17 00:00:00 2001 From: Jini Date: Thu, 12 Feb 2026 00:00:29 +0900 Subject: [PATCH 10/14] =?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=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DuZzonKuVM+ImageConversion.swift | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift index 5b41dc42..84d37011 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+ImageConversion.swift @@ -6,7 +6,157 @@ // import SwiftUI +import Nuke extension DuZzonKuVM { + // MARK: - Photo + Frame Composition (여러 개 지원) + + /// 선택한 사진들과 프레임을 합성하여 bodyImage로 저장 + func composePhotoWithFrame() async { + guard let frame = selectedFrame, + let frameURL = URL(string: frame.frameURL) else { + return + } + + // 합성 시작 + await MainActor.run { + isComposingPhoto = true + } + + defer { + Task { @MainActor in + isComposingPhoto = false + } + } + + // 프레임 이미지 다운로드 + guard let originalFrameImage = await downloadImage(from: frameURL) else { + return + } + + // 체커보드 이미지 다운로드 (선택사항) + var checkerboardImage: UIImage? = nil + if let checkerBoardURLString = frame.checkerBoardURL, + let checkerBoardURL = URL(string: checkerBoardURLString) { + checkerboardImage = await downloadImage(from: checkerBoardURL) + } + + // DuZzonKuFramePreviewView와 동일한 크기로 합성 + let targetFrameHeight: CGFloat = 376 + let frameAspect = originalFrameImage.size.width / originalFrameImage.size.height + let targetFrameWidth = targetFrameHeight * frameAspect + let targetFrameSize = CGSize(width: targetFrameWidth, height: targetFrameHeight) + + // Firebase에서 정의한 체커보드 영역들 + guard let checkerBoardRects = frame.checkerBoardRects else { + // checkerBoardRects가 없으면 프레임만 저장 + bodyImage = originalFrameImage + return + } + + let renderer = UIGraphicsImageRenderer(size: targetFrameSize) + + let composedImage = renderer.image { context in + + // 1. 각 체커보드 영역에 체커보드 + 사진 그리기 + for (index, rect) in checkerBoardRects.enumerated() { + let photoWidth = targetFrameWidth * rect.width + let photoHeight = targetFrameHeight * rect.height + let photoX = targetFrameWidth * rect.x + let photoY = targetFrameHeight * rect.y + let photoRect = CGRect(x: photoX, y: photoY, width: photoWidth, height: photoHeight) + + // 1-1. 체커보드 배경 (선택사항) + if let checkerboard = checkerboardImage { + context.cgContext.saveGState() + context.cgContext.addRect(photoRect) + context.cgContext.clip() + checkerboard.draw(in: photoRect) + context.cgContext.restoreGState() + } + + // 1-2. 해당 인덱스의 사진 그리기 + if let photo = getPhoto(at: index) { + context.cgContext.saveGState() + context.cgContext.addRect(photoRect) + context.cgContext.clip() + + // 사진을 영역에 맞게 scaledToFill로 그리기 + let photoAspect = photo.size.width / photo.size.height + let rectAspect = photoRect.width / photoRect.height + + var drawRect = photoRect + if photoAspect > rectAspect { + // 사진이 더 넓음 - 높이 기준으로 맞춤 + let scaledWidth = photoRect.height * photoAspect + drawRect = CGRect( + x: photoRect.midX - scaledWidth / 2, + y: photoRect.minY, + width: scaledWidth, + height: photoRect.height + ) + } else { + // 사진이 더 높음 - 너비 기준으로 맞춤 + let scaledHeight = photoRect.width / photoAspect + drawRect = CGRect( + x: photoRect.minX, + y: photoRect.midY - scaledHeight / 2, + width: photoRect.width, + height: scaledHeight + ) + } + + // 사진 변환 적용 (확대/축소, 회전, 이동) + // 현재는 모든 사진에 동일한 변환 적용 + // 필요시 인덱스별로 다른 변환 저장 가능 + let centerX = drawRect.midX + let centerY = drawRect.midY + + context.cgContext.translateBy(x: centerX, y: centerY) + context.cgContext.translateBy(x: photoOffset.width, y: photoOffset.height) + context.cgContext.rotate(by: CGFloat(photoRotation.radians)) + context.cgContext.scaleBy(x: photoScale, y: photoScale) + + let centeredRect = CGRect( + x: -drawRect.width / 2, + y: -drawRect.height / 2, + width: drawRect.width, + height: drawRect.height + ) + photo.draw(in: centeredRect) + context.cgContext.restoreGState() + } + + // 2. 프레임 이미지 + originalFrameImage.draw(in: CGRect(origin: .zero, size: targetFrameSize)) + } + } + + bodyImage = composedImage + } + + // MARK: - Helper: Download Image + + private func downloadImage(from url: URL) async -> UIImage? { + // Bundle에서 먼저 확인 (로컬 이미지인 경우) + if url.scheme == nil || url.scheme == "file" { + let imageName = url.lastPathComponent.replacingOccurrences(of: ".png", with: "") + return UIImage(named: imageName) + } + + // 원격 이미지 다운로드 + return await withCheckedContinuation { continuation in + Task { + do { + let imageRequest = ImageRequest(url: url) + let response = try await ImagePipeline.shared.image(for: imageRequest) + continuation.resume(returning: response) + } catch { + print("Failed to download image: \(error)") + continuation.resume(returning: nil) + } + } + } + } } From f44284fadf75192dabeda0b880fb197c7c476205 Mon Sep 17 00:00:00 2001 From: Jini Date: Thu, 12 Feb 2026 00:54:19 +0900 Subject: [PATCH 11/14] =?UTF-8?q?style:=20=EC=82=AC=EC=A7=84=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EB=8F=84=20=EB=AA=A8=EC=84=9C=EB=A6=AC=20?= =?UTF-8?q?=EB=91=A5=EA=B8=80=EA=B2=8C=20=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift index 7f6128b1..781505e5 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift @@ -339,6 +339,7 @@ struct DuZzonKuFramePreviewView: View { // 사진이 없을 때 딤 오버레이 Color.black20 .frame(width: photoWidth, height: photoHeight) + .clipShape(clipShape) } // 버튼들 From 7153a0ce29dbf35946b67f60a2204ee683053199 Mon Sep 17 00:00:00 2001 From: Jini Date: Thu, 12 Feb 2026 01:23:33 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=ED=94=84=EB=A0=88=EC=9E=84=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=EB=8C=80=EB=A1=9C=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift | 2 +- .../Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift index 781505e5..9b552514 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift @@ -150,7 +150,7 @@ struct DuZzonKuFramePreviewView: View { // 일반 SwiftUI View는 즉시 준비 완료 onSceneReady() } - }/* width 0.46*/ + } private var photoGestures: some Gesture { // 확대/축소 diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift index 6eceb402..b6d4fa9e 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM+Firebase.swift @@ -90,6 +90,7 @@ extension DuZzonKuVM { .collection("Template") .document("DuZzonKu") .collection("Frames") + .order(by: "order", descending: false) .getDocuments() availableFrames = try framesSnapshot.documents.compactMap { From 35208e05d4d829ea7686f20ec3369b8a798e20bb Mon Sep 17 00:00:00 2001 From: Jini Date: Thu, 12 Feb 2026 01:27:40 +0900 Subject: [PATCH 13/14] =?UTF-8?q?style:=20=ED=94=84=EB=A0=88=EC=9E=84=20?= =?UTF-8?q?=EC=85=80=EB=A0=89=ED=8A=B8=EB=B7=B0=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift | 2 +- .../Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift index 924aa8ec..0a0884f1 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift @@ -126,7 +126,7 @@ class DuZzonKuVM: KeyringViewModelProtocol { func bottomViewHeightRatio(for mode: CustomizingMode) -> CGFloat { switch mode { case .frame: - return 0.3 // 프레임 모드는 더 낮은 높이 + return 0.26 // 프레임 모드는 더 낮은 높이 case .effect: return 0.3 // 이펙트 모드도 같은 높이 default: diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift index f55c064c..c32c2979 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/Views/DuZzonKuFrameSelectorView.swift @@ -12,7 +12,7 @@ struct DuZzonKuFrameSelectorView: View { @Bindable var viewModel: DuZzonKuVM var body: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { // MARK: - 프레임 섹션 Text("프레임") .typography(.suit16B) @@ -83,7 +83,7 @@ struct DuZzonKuFrameSelectorView: View { // 프레임 이름 Text(frame.name) - .typography(isSelected ? .notosans12SB : .notosans12M) + .typography(isSelected ? .notosans14SB : .notosans14R) .foregroundStyle(isSelected ? .main500 : .black100) .lineLimit(1) .truncationMode(.tail) From 80c8259ff6c900aec0872cbf8a74c6c3f7f4cde6 Mon Sep 17 00:00:00 2001 From: Jini Date: Thu, 12 Feb 2026 02:18:58 +0900 Subject: [PATCH 14/14] =?UTF-8?q?style:=20=ED=82=A4=EB=A7=81=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift index 9b552514..b5be40ce 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuFramePreviewView.swift @@ -49,7 +49,7 @@ struct DuZzonKuFramePreviewView: View { // 프레임 + 안장 + 갈기 합성 영역 VStack { Spacer() - .frame(height: 95) + .frame(height: 135) compositionView } @@ -59,7 +59,7 @@ struct DuZzonKuFramePreviewView: View { .resizable() .scaledToFit() .frame(width: 90) - .offset(y: -31) + .offset(y: 4) } Spacer()