From aa9ce7fde768c3ae6210300dd6b34e86bd60f346 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sat, 7 Feb 2026 21:52:00 +0900 Subject: [PATCH 1/7] =?UTF-8?q?style:=20=EB=AD=89=EC=B9=98=20=ED=82=A4?= =?UTF-8?q?=EB=A7=81=20=EA=B1=B8=EA=B8=B0=20-=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Bundle/Views/Shared/AddKeyringButton.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/AddKeyringButton.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/AddKeyringButton.swift index a5b59331..b0a9ab39 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/AddKeyringButton.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/AddKeyringButton.swift @@ -16,6 +16,8 @@ struct AddKeyringButton: View { action() } label: { Image(.plus) + .resizable() + .frame(width: 21.48, height: 21.48) } .frame(width: 30, height: 30) .glassEffect(.clear.interactive(), in: .circle) From ae3e23cd265694f374acb9f24d3436756e0f580d Mon Sep 17 00:00:00 2001 From: giljihun Date: Sun, 8 Feb 2026 00:35:10 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EB=AD=89=EC=B9=98=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=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 - BundleCompleteView: 뭉치 생성 완료 후 표시되는 완성 화면 - 이미지 저장, 영상 공유, 대표 뭉치 설정 기능 - KeyringCompleteView와 동일한 구조 적용 --- .../BundleCompleteView+SaveImage.swift | 146 ++++++ .../BundleCompleteView+VideoGen.swift | 130 +++++ .../Views/Complete/BundleCompleteView.swift | 477 ++++++++++++++++++ 3 files changed, 753 insertions(+) create mode 100644 Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift create mode 100644 Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+VideoGen.swift create mode 100644 Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift new file mode 100644 index 00000000..d9260bfc --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+SaveImage.swift @@ -0,0 +1,146 @@ +// +// BundleCompleteView+SaveImage.swift +// Keychy +// +// Created by 길지훈 on 2/7/26. +// +// 뭉치 완성뷰 - 이미지 캡처 및 저장 기능 + +import SwiftUI +import Photos + +// MARK: - Photo Library Save +extension BundleCompleteView { + + /// 포토 라이브러리 권한 요청 + func requestPhotoLibraryPermission(completion: @escaping (Bool) -> Void) { + let status = PHPhotoLibrary.authorizationStatus() + + switch status { + case .authorized, .limited: + completion(true) + case .denied, .restricted: + completion(false) + case .notDetermined: + PHPhotoLibrary.requestAuthorization { newStatus in + DispatchQueue.main.async { + completion(newStatus == .authorized || newStatus == .limited) + } + } + @unknown default: + completion(false) + } + } + + /// 이미지를 포토 라이브러리에 저장 + @MainActor + func saveImageToLibrary(_ image: UIImage) async { + await withCheckedContinuation { continuation in + requestPhotoLibraryPermission { granted in + guard granted else { + Task { @MainActor in + isCapturing = false + } + continuation.resume() + return + } + + PHPhotoLibrary.shared().performChanges({ + PHAssetChangeRequest.creationRequestForAsset(from: image) + }) { success, error in + Task { @MainActor in + if success { + print("[BundleCompleteView] 이미지 저장 성공") + showImageSaved = true + } else if let error = error { + print("[BundleCompleteView] 이미지 저장 실패: \(error.localizedDescription)") + } + + // 캡처 상태 해제 + isCapturing = false + } + continuation.resume() + } + } + } + } + + /// 이미지 캡처 및 저장 (메인 함수) - 로컬 데이터 사용 + func captureAndSaveImage() { + guard let carabiner = bundleVM.selectedCarabiner, + let background = bundleVM.selectedBackground else { return } + + // 캡쳐 시작 + withAnimation(.none) { + isCapturing = true + } + + Task { + // 캡쳐용 키링 데이터 생성 (로컬 데이터 사용) + var captureKeyringDataList: [MultiKeyringCaptureScene.KeyringData] = [] + let selectedKeyrings = bundleVM.selectedKeyringsForBundle + + for (index, keyring) in selectedKeyrings.sorted(by: { $0.key < $1.key }) { + guard index < carabiner.maxKeyringCount else { continue } + + captureKeyringDataList.append( + MultiKeyringCaptureScene.KeyringData( + index: index, + position: CGPoint( + x: carabiner.keyringXPosition[index], + y: carabiner.keyringYPosition[index] + ), + bodyImageURL: keyring.bodyImage, + hookOffsetY: keyring.hookOffsetY, + chainLength: keyring.chainLength + ) + ) + } + + let carabinerType = CarabinerType.from(carabiner.carabinerType) + let carabinerBackURL: String? + let carabinerFrontURL: String? + + if carabinerType == .hamburger { + carabinerBackURL = carabiner.carabinerImage[1] + carabinerFrontURL = carabiner.carabinerImage[2] + } else { + // plain 타입일 때 + carabinerBackURL = carabiner.carabinerImage[0] + carabinerFrontURL = nil + } + + // 배경 포함 캡쳐 + guard let fullImageData = await MultiKeyringCaptureScene.captureBundleImage( + keyringDataList: captureKeyringDataList, + backgroundImageURL: background.backgroundImage, + carabinerBackImageURL: carabinerBackURL, + carabinerFrontImageURL: carabinerFrontURL, + carabinerType: carabinerType, + carabinerX: carabiner.carabinerX, + carabinerY: carabiner.carabinerY, + carabinerWidth: carabiner.carabinerWidth + ) else { + await MainActor.run { + isCapturing = false + } + return + } + + // viewModel에 캡쳐된 이미지 저장 + await MainActor.run { + bundleVM.bundleCapturedImage = fullImageData + } + + // PNG 데이터를 UIImage로 변환하여 포토 라이브러리에 저장 + guard let image = UIImage(data: fullImageData) else { + await MainActor.run { + isCapturing = false + } + return + } + + await saveImageToLibrary(image) + } + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+VideoGen.swift b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+VideoGen.swift new file mode 100644 index 00000000..d4ec3794 --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView+VideoGen.swift @@ -0,0 +1,130 @@ +// +// BundleCompleteView+VideoGen.swift +// Keychy +// +// Created by 길지훈 on 2/7/26. +// +// 뭉치 완성뷰 - 영상 생성 기능 + +import SwiftUI +import Photos + +// MARK: - Video Generation +extension BundleCompleteView { + + /// 공유용 영상 생성 (캐싱) + @MainActor + func generateVideoForShare() async { + isGeneratingVideo = true + + do { + guard let carabiner = bundleVM.selectedCarabiner, + let background = bundleVM.selectedBackground else { + isGeneratingVideo = false + return + } + + // 배경 이미지 로드 + let backgroundImage = await loadImage(from: background.backgroundImage) + + // 영상 생성 + let videoURL = try await videoGenerator.generateVideo( + keyringDataList: keyringDataList, + backgroundImage: backgroundImage, + backgroundImageURL: background.backgroundImage, + carabinerBackImageURL: carabiner.backImageURL, + carabinerFrontImageURL: carabiner.frontImageURL, + carabinerX: carabiner.carabinerX, + carabinerY: carabiner.carabinerY, + carabinerWidth: carabiner.carabinerWidth, + carabinerType: carabiner.type, + bundleScale: 2.5 + ) + + cachedVideoURL = videoURL + isGeneratingVideo = false + + // UI 업데이트 완료 대기 후 시트 표시 + try? await Task.sleep(for: .seconds(0.3)) + showShareSheet = true + + } catch { + print("[BundleCompleteView] 영상 생성 실패: \(error)") + isGeneratingVideo = false + } + } + + /// 영상 생성 및 사진 앨범에 저장 + @MainActor + func generateAndSaveVideo() async { + isGeneratingVideo = true + + do { + guard let carabiner = bundleVM.selectedCarabiner, + let background = bundleVM.selectedBackground else { + isGeneratingVideo = false + return + } + + // 배경 이미지 로드 + let backgroundImage = await loadImage(from: background.backgroundImage) + + // 영상 생성 + let videoURL = try await videoGenerator.generateVideo( + keyringDataList: keyringDataList, + backgroundImage: backgroundImage, + backgroundImageURL: background.backgroundImage, + carabinerBackImageURL: carabiner.backImageURL, + carabinerFrontImageURL: carabiner.frontImageURL, + carabinerX: carabiner.carabinerX, + carabinerY: carabiner.carabinerY, + carabinerWidth: carabiner.carabinerWidth, + carabinerType: carabiner.type, + bundleScale: 2.5 + ) + + // 사진 앨범 저장 + try await saveVideoToPhotoLibrary(url: videoURL) + + // 임시 파일 삭제 + try? FileManager.default.removeItem(at: videoURL) + + // 성공 Alert 표시 + isGeneratingVideo = false + showVideoSaved = true + + } catch { + print("[BundleCompleteView] 영상 생성 실패: \(error)") + isGeneratingVideo = false + } + } + + /// 비디오 파일을 사진 라이브러리에 저장 + private func saveVideoToPhotoLibrary(url: URL) async throws { + try await PHPhotoLibrary.shared().performChanges { + PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url) + } + } + + /// URL에서 이미지 로드 + private func loadImage(from urlString: String) async -> UIImage? { + guard let url = URL(string: urlString) else { + return nil + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + return UIImage(data: data) + } catch { + print("[BundleCompleteView] 배경 이미지 로드 실패: \(error)") + return nil + } + } + + /// 캐시된 영상 파일 삭제 + func cleanupCachedVideo() { + guard let url = cachedVideoURL else { return } + try? FileManager.default.removeItem(at: url) + cachedVideoURL = nil + } +} diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift new file mode 100644 index 00000000..b3068daa --- /dev/null +++ b/Keychy/Keychy/Presentation/Bundle/Views/Complete/BundleCompleteView.swift @@ -0,0 +1,477 @@ +// +// BundleCompleteView.swift +// Keychy +// +// Created by 길지훈 on 2/7/26. +// +// 뭉치 완성 화면 +// - 뭉치 생성 완료 후 표시되는 완성뷰 + +import SwiftUI +import SpriteKit + +struct BundleCompleteView: View { + @Bindable var router: NavigationRouter + @State var collectionVM: CollectionViewModel + @Bindable var bundleVM: BundleViewModel + + + // MARK: - Video Generation + @State var videoGenerator = BundleVideoGenerator() + @State var isGeneratingVideo = false + @State var cachedVideoURL: URL? + @State var showShareSheet = false + + // MARK: - Image Capture + @State var isCapturing = false + @State var showImageSaved = false + @State var showVideoSaved = false + + // MARK: - Main Bundle Setting + @State var showSetMainAlert = false + @State var showMainChanged = false + @State var showAlreadyMainToast = false + + // MARK: - Scene + @State var isInteractionEnabled = false + @State var isSceneReady = false + @State var keyringDataList: [MultiKeyringScene.KeyringData] = [] + + var body: some View { + ZStack { + // 1. 메인 씬 (배경 포함) - blur 적용 + sceneContent + .blur(radius: isAlertShowing ? 15 : 0) + + // 2. 하단 정보 + 버튼 오버레이 - blur 적용 + VStack { + Spacer() + bottomOverlay + .cinematicAppear(delay: 0.6, duration: 0.8, style: .slideUp) + } + .padding(.bottom, 40) + .blur(radius: isAlertShowing ? 15 : 0) + + // 3. Alerts 오버레이 - blur 없음 + alertsOverlay + + // 4. 로딩 오버레이 + loadingOverlay + } + .ignoresSafeArea() + .navigationBarBackButtonHidden() + .toolbar { + closeToolbarItem + titleToolbarItem + inventoryToolbarItem + } + .withToast(position: .default) + .sheet(isPresented: $showShareSheet) { + if let url = cachedVideoURL { + ShareSheet(items: [url]) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } + .task { + await loadBundleData() + } + } +} + +// MARK: - Scene Content +extension BundleCompleteView { + /// 메인 씬 (배경 + 카라비너 + 키링) + @ViewBuilder + private var sceneContent: some View { + if let carabiner = bundleVM.selectedCarabiner, + let background = bundleVM.selectedBackground { + + MultiKeyringSceneView( + keyringDataList: keyringDataList, + ringType: .basic, + chainType: .basic, + backgroundColor: .clear, + backgroundImageURL: background.backgroundImage, + carabinerBackImageURL: carabiner.backImageURL, + carabinerFrontImageURL: carabiner.frontImageURL, + carabinerX: carabiner.carabinerX, + carabinerY: carabiner.carabinerY, + carabinerWidth: carabiner.carabinerWidth, + currentCarabinerType: carabiner.type, + onAllKeyringsReady: { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.easeOut(duration: 0.3)) { + isSceneReady = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + isInteractionEnabled = true + } + } + } + ) + .allowsHitTesting(isInteractionEnabled) + .id("scene_\(background.id ?? "")_\(carabiner.id ?? "")_\(keyringDataList.map { "\($0.index)_\($0.bodyImageURL.hashValue)" }.joined(separator: "_"))") + } + } + + /// 로딩 오버레이 + @ViewBuilder + private var loadingOverlay: some View { + if !isSceneReady { + Color.black20 + .ignoresSafeArea() + + LoadingAlert(type: .longWithKeychy, message: "뭉치를 불러오고 있어요") + } + + if isGeneratingVideo { + Color.black20 + .ignoresSafeArea() + + LoadingAlert(type: .longWithKeychy, message: "공유할 영상을 만들고 있어요!") + } + } + + /// 뭉치 데이터 로드 - BundleCreateView에서 이미 설정된 데이터 사용 + @MainActor + private func loadBundleData() async { + guard let carabiner = bundleVM.selectedCarabiner else { + isSceneReady = true + return + } + + // BundleCreateView에서 설정한 selectedKeyringsForBundle 사용 + let selectedKeyrings = bundleVM.selectedKeyringsForBundle + + // 키링 데이터 생성 (Firebase 호출 없이 로컬 데이터 사용) + var dataList: [MultiKeyringScene.KeyringData] = [] + + for (index, keyring) in selectedKeyrings.sorted(by: { $0.key < $1.key }) { + guard index < carabiner.maxKeyringCount else { continue } + + let soundId = keyring.soundId + let customSoundURL: URL? = { + if soundId.hasPrefix("https://") || soundId.hasPrefix("http://") { + return URL(string: soundId) + } + return nil + }() + + let data = MultiKeyringScene.KeyringData( + index: index, + position: CGPoint( + x: carabiner.keyringXPosition[index], + y: carabiner.keyringYPosition[index] + ), + bodyImageURL: keyring.bodyImage, + templateId: keyring.selectedTemplate, + soundId: soundId, + customSoundURL: customSoundURL, + particleId: keyring.particleId, + hookOffsetY: keyring.hookOffsetY, + chainLength: keyring.chainLength + ) + dataList.append(data) + } + + keyringDataList = dataList + + // 키링이 없으면 바로 준비 완료 + if dataList.isEmpty { + isSceneReady = true + } + } +} + +// MARK: - Bottom Overlay (정보 + 버튼) +extension BundleCompleteView { + private var bottomOverlay: some View { + VStack(spacing: 20) { + // 뭉치 정보 + bundleInfo + + // 액션 버튼 + actionButtons + .opacity(isCapturing ? 0 : 1) + } + } + + private var bundleInfo: some View { + VStack(spacing: 0) { + if let bundle = bundleVM.selectedBundle { + Text(bundle.name) + .typography(.malang26B) + .foregroundStyle(.black100) + .padding(.bottom, 2) + + Text(formattedDate(date: bundle.createdAt)) + .typography(.suit14M) + .foregroundStyle(.black100) + } + } + } + + func formattedDate(date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy년 M월 d일" + return formatter.string(from: date) + } +} + +// MARK: - Action Buttons +extension BundleCompleteView { + private func actionButton( + image: ImageResource, + title: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + VStack(spacing: 4) { + Image(image) + Text(title) + .typography(.suit12M) + .foregroundStyle(.black100) + } + .frame(width: 74, height: 47) + .padding(.vertical, 11.5) + .padding(.horizontal, 8) + } + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) + } + + private var actionButtons: some View { + HStack(spacing: 17) { + // 이미지 저장 + actionButton(image: .saveBlack, title: "이미지 저장") { + captureAndSaveImage() + } + + // 공유 + actionButton(image: .share, title: "공유") { + if cachedVideoURL != nil { + showShareSheet = true + return + } + Task { + await generateVideoForShare() + } + } + + // 대표로 설정 + actionButton(image: .starFill, title: "대표로 설정") { + handleSetMainButtonTap() + } + } + } +} + +// MARK: - Toolbar Items +extension BundleCompleteView { + private var isAlertShowing: Bool { + showImageSaved || showVideoSaved || isGeneratingVideo || + showSetMainAlert || showMainChanged || showAlreadyMainToast || !isSceneReady + } + + var closeToolbarItem: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + cleanupCachedVideo() + TabBarManager.show() + router.reset() + } label: { + Image(.dismissGray600) + } + .opacity(isAlertShowing ? 0 : 1) + .allowsHitTesting(!isAlertShowing) + } + .sharedBackgroundVisibility(isAlertShowing ? .hidden : .visible) + } + + var titleToolbarItem: some ToolbarContent { + ToolbarItem(placement: .principal) { + Text("뭉치 완성!") + .typography(.notosans17M) + .foregroundStyle(.black100) + .opacity(isAlertShowing ? 0 : 1) + } + } + + var inventoryToolbarItem: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + navigateToInventory() + } label: { + Image(.goToCollection) + } + .opacity(isAlertShowing ? 0 : 1) + .allowsHitTesting(!isAlertShowing) + } + .sharedBackgroundVisibility(isAlertShowing ? .hidden : .visible) + } + + private func navigateToInventory() { + cleanupCachedVideo() + TabBarManager.show() + router.reset() + router.push(.bundleInventoryView) + } +} + +// MARK: - Alerts +extension BundleCompleteView { + @ViewBuilder + private var alertsOverlay: some View { + KeychyAlert( + type: .imageSave, + message: "이미지가 저장되었어요!", + isPresented: $showImageSaved + ) + + KeychyAlert( + type: .imageSave, + message: "영상이 저장되었어요!", + isPresented: $showVideoSaved + ) + + KeychyAlert( + type: .checkmark, + message: "대표 뭉치가 변경되었어요!", + isPresented: $showMainChanged + ) + + // 이미 대표 뭉치 토스트 + if showAlreadyMainToast { + Color.black20 + .ignoresSafeArea() + .zIndex(99) + + Text("이미 대표 뭉치로 설정되어 있어요") + .typography(.suit17SB) + .foregroundColor(.black100) + .frame(maxWidth: .infinity) + .frame(height: 73) + .padding(.horizontal, 51) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 34)) + .zIndex(100) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showAlreadyMainToast = false + } + } + } + } + + // 대표 설정 확인 팝업 + if showSetMainAlert { + Color.black20 + .ignoresSafeArea() + .zIndex(99) + + setMainAlertView + .zIndex(100) + } + } + + private var setMainAlertView: some View { + VStack(spacing: 24) { + VStack(spacing: 10) { + Image(.bangMark) + .padding(.vertical, 4) + + Text("대표 뭉치로 설정할까요?") + .typography(.suit20B) + .foregroundStyle(.black100) + Text("선택한 뭉치가 홈에 걸려요.") + .typography(.suit15R) + .foregroundStyle(.black100) + } + .padding(8) + + HStack(spacing: 16) { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSetMainAlert = false + } + } label: { + Text("취소") + .typography(.suit17SB) + .foregroundStyle(.black100) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.glassProminent) + .tint(.black10) + + Button { + handleSetMainConfirm() + } label: { + Text("확인") + .typography(.suit17SB) + .foregroundStyle(.white100) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.glassProminent) + .tint(.main500) + } + } + .padding(14) + .glassEffect(in: .rect(cornerRadius: 34)) + .padding(.horizontal, 51) + } +} + +// MARK: - 대표 설정 처리 +extension BundleCompleteView { + private func handleSetMainButtonTap() { + guard let bundle = bundleVM.selectedBundle else { return } + + if bundle.isMain { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showAlreadyMainToast = true + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSetMainAlert = true + } + } + } + + private func handleSetMainConfirm() { + guard NetworkManager.shared.isConnected else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSetMainAlert = false + } + ToastManager.shared.show() + return + } + + guard let bundle = bundleVM.selectedBundle else { return } + + bundleVM.updateBundleMainStatus(bundle: bundle, isMain: true) { success in + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSetMainAlert = false + } + + if success { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showMainChanged = true + } + } + } + } +} + +// MARK: - Preview +#Preview { + NavigationStack { + BundleCompleteView( + router: NavigationRouter(), + collectionVM: CollectionViewModel(), + bundleVM: BundleViewModel() + ) + } +} From 9ca76685e934083c6b8fb614937872d3a7c1eac4 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sun, 8 Feb 2026 00:35:30 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20bundleCompleteView=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=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/Core/Navigation/Routes/BundleRoute.swift | 1 + Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift | 1 + Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift | 1 + Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift | 1 + 4 files changed, 4 insertions(+) diff --git a/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift index ddb56f52..72577575 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/BundleRoute.swift @@ -15,5 +15,6 @@ protocol BundleRoute: Hashable { static var bundleNameInputView: Self { get } static var bundleNameEditView: Self { get } static var bundleEditView: Self { get } + static var bundleCompleteView: Self { get } static var coinCharge: Self { get } } diff --git a/Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift index 7d6ccf24..8500b150 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/CollectionRoute.swift @@ -22,6 +22,7 @@ enum CollectionRoute: Hashable, BundleRoute { case bundleNameInputView case bundleNameEditView case bundleEditView + case bundleCompleteView // 위젯 안내 case widgetSettingView diff --git a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift index e32b99fb..d0b33fae 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/HomeRoute.swift @@ -14,6 +14,7 @@ enum HomeRoute: Hashable, BundleRoute { case bundleNameInputView case bundleNameEditView case bundleEditView + case bundleCompleteView // Home case coinCharge diff --git a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift index 2ca1788b..869c7f76 100644 --- a/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift +++ b/Keychy/Keychy/Core/Navigation/Routes/WorkshopRoute.swift @@ -21,6 +21,7 @@ enum WorkshopRoute: Hashable, BundleRoute { case bundleNameInputView case bundleNameEditView case bundleEditView + case bundleCompleteView // MARK: - 아크릴 포토 템플릿 case acrylicPhotoPreview From af69c6a607fcc6baf0def30ace5a2e7e60f2b3c1 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sun, 8 Feb 2026 00:35:52 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EB=AD=89=EC=B9=98=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=EB=B7=B0=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 각 Tab의 navigationDestination에 bundleCompleteView 추가 --- Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift | 2 ++ Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift | 2 ++ Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift | 2 ++ 3 files changed, 6 insertions(+) diff --git a/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift b/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift index 1a6f3969..b740a8c6 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/CollectionTab.swift @@ -37,6 +37,8 @@ struct CollectionTab: View { BundleNameEditView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleEditView: BundleEditView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) + case .bundleCompleteView: + BundleCompleteView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .widgetSettingView: WidgetSettingView(router: router) case .packageCompleteView(let keyring, let postOfficeId): diff --git a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift index 0fd7d8a6..2e98ba8d 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/HomeTab.swift @@ -44,6 +44,8 @@ struct HomeTab: View { BundleNameEditView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleEditView: BundleEditView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) + case .bundleCompleteView: + BundleCompleteView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) // 재화 충전 case .coinCharge: CoinChargeView(router: router) diff --git a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift index 02c89207..4872b23f 100644 --- a/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift +++ b/Keychy/Keychy/Presentation/Tab/Views/WorkshopTab.swift @@ -228,6 +228,8 @@ struct WorkshopTab: View { BundleNameEditView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) case .bundleEditView: BundleEditView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) + case .bundleCompleteView: + BundleCompleteView(router: router, collectionVM: collectionViewModel, bundleVM: bundleViewModel) } } From 8f1b8009da8250e65ae9df86fe1d9d11689f4d8f Mon Sep 17 00:00:00 2001 From: giljihun Date: Sun, 8 Feb 2026 00:36:36 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=ED=82=A4=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0=20(?= =?UTF-8?q?=EB=B2=A0=EB=A6=AC=EB=B2=A0=EB=A6=AC=20=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=EC=BB=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UIView.setAnimationsEnabled(false) 대신 withTransaction 사용 - 전역 애니메이션 비활성화 문제 해결 (시트 애니메이션 깨짐 수정) - 갑자기 뭉치 만들고 나오면, 모든 ui의 애니메이션이 동작안하는거있지 --- .../Views/Create/BundleNameInputView.swift | 22 +++++++++++-------- .../Views/Edit/BundleNameEditView.swift | 15 +++++++++---- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleNameInputView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleNameInputView.swift index 33080d62..44ad6ce1 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleNameInputView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Create/BundleNameInputView.swift @@ -72,16 +72,22 @@ struct BundleNameInputView: View { .onTapGesture { isTextFieldFocused = false } - // 키보드 올라옴 내려옴을 감지하는 notification center, 개발록 '키보드가 올라오면서 화면을 가릴 때'에서 소개한 내용과 같습니다. + // 키보드 높이 변경 시 SwiftUI 애니메이션 비활성화 .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { - keyboardHeight = keyboardFrame.height - UIView.setAnimationsEnabled(false) + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + keyboardHeight = keyboardFrame.height + } } } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in - keyboardHeight = 0 - UIView.setAnimationsEnabled(false) + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + keyboardHeight = 0 + } } } } @@ -225,10 +231,8 @@ extension BundleNameInputView { // 생성된 번들을 selectedBundle에 할당 // createBundle의 completion이 배열 업데이트 후 호출되므로 안전 bundleVM.selectedBundle = bundleVM.bundles.first { $0.documentId == bundleId } - router.reset() - router.push(.bundleInventoryView) - // 네비게이션: 상세 페이지로 이동 - router.push(.bundleDetailView) + // 네비게이션: 완성 화면으로 이동 + router.push(.bundleCompleteView) } else { // 실패 처리 isUploading = false diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleNameEditView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleNameEditView.swift index fcb10267..06ab3bbf 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleNameEditView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Edit/BundleNameEditView.swift @@ -55,15 +55,22 @@ struct BundleNameEditView: View { } TabBarManager.hide() } + // 키보드 높이 변경 시 SwiftUI 애니메이션 비활성화 .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { - keyboardHeight = keyboardFrame.height - UIView.setAnimationsEnabled(false) + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + keyboardHeight = keyboardFrame.height + } } } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in - keyboardHeight = 0 - UIView.setAnimationsEnabled(false) + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + keyboardHeight = 0 + } } } } From 8e93d140d6191e29e5e3216fce8f6fe032dbd922 Mon Sep 17 00:00:00 2001 From: giljihun Date: Sun, 8 Feb 2026 00:37:05 +0900 Subject: [PATCH 6/7] =?UTF-8?q?style:=20=ED=82=A4=EB=A7=81=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=EB=B7=B0=20=EB=A1=9C=EB=94=A9=20UI=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 영상 생성 로딩을 LoadingAlert 컴포넌트로 변경 --- .../Shared/Views/KeyringCompleteView.swift | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift index 5b3f4677..2e74a733 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringCompleteView.swift @@ -68,6 +68,8 @@ struct KeyringCompleteView: View { .sheet(isPresented: $showShareSheet) { if let url = cachedVideoURL { ShareSheet(items: [url]) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } } } @@ -164,23 +166,8 @@ extension KeyringCompleteView { if isGeneratingVideo { Color.black20 .ignoresSafeArea() - - VStack(spacing: 20) { - ProgressView() - .scaleEffect(1.5) - .tint(.white) - - Text("영상 생성 중...") - .typography(.suit17SB) - .foregroundColor(.white) - - Text("5~10초 소요") - .typography(.suit14M) - .foregroundColor(.white.opacity(0.7)) - } - .padding(40) - .background(.ultraThinMaterial) - .cornerRadius(20) + + LoadingAlert(type: .longWithKeychy, message: "공유할 영상을 만들고 있어요!") } } } From f0e12b5eb7b5804fab6a5eaa879bb1cb6691fdeb Mon Sep 17 00:00:00 2001 From: giljihun Date: Sun, 8 Feb 2026 00:37:27 +0900 Subject: [PATCH 7/7] =?UTF-8?q?style:=20=EB=B2=88=EB=93=A4=20=ED=82=A4?= =?UTF-8?q?=EB=A7=81=20=EC=8B=9C=ED=8A=B8=20-=20=EC=85=80=EC=9D=98=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=EC=9E=90=20=EC=A2=80=20=EC=95=BD=ED=95=98?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Keychy/Keychy.xcodeproj/project.pbxproj | 20 +++++++++++++++++++ .../Views/Shared/BundleKeyringCellView.swift | 8 ++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index 48f5546c..56bf3993 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -359,6 +359,9 @@ AA2146B72F15E5B60048D40E /* BundleEditView+SelectSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146B62F15E5B60048D40E /* BundleEditView+SelectSheet.swift */; }; AA2146BB2F161D0C0048D40E /* BundleEditView+Initialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2146BA2F161D0C0048D40E /* BundleEditView+Initialization.swift */; }; AA3908F82EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3908F72EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift */; }; + BC4CMPLT2F3B123400000001 /* BundleCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */; }; + BC5CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */; }; + BC6CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */; }; AA3909462EC9F29500D87EEC /* UIApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */; }; AA39098E2ECA061700D87EEC /* GridItemSpacing.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA39098D2ECA061700D87EEC /* GridItemSpacing.swift */; }; AA390CE52ECC60A700D87EEC /* BundleRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA390CE42ECC60A700D87EEC /* BundleRoute.swift */; }; @@ -819,6 +822,9 @@ AA2146B62F15E5B60048D40E /* BundleEditView+SelectSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+SelectSheet.swift"; sourceTree = ""; }; AA2146BA2F161D0C0048D40E /* BundleEditView+Initialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleEditView+Initialization.swift"; sourceTree = ""; }; AA3908F72EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleDetailView+SaveImage.swift"; sourceTree = ""; }; + BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleCompleteView.swift; sourceTree = ""; }; + BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCompleteView+VideoGen.swift"; sourceTree = ""; }; + BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BundleCompleteView+SaveImage.swift"; sourceTree = ""; }; AA3909452EC9F29500D87EEC /* UIApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Extension.swift"; sourceTree = ""; }; AA39098D2ECA061700D87EEC /* GridItemSpacing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridItemSpacing.swift; sourceTree = ""; }; AA390CE42ECC60A700D87EEC /* BundleRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleRoute.swift; sourceTree = ""; }; @@ -2177,6 +2183,7 @@ AA8C9B962F10E6A500A352D2 /* Views */ = { isa = PBXGroup; children = ( + BC0CMPLT2F3B123400000000 /* Complete */, D6A1E83B3F994B7DA46CDC7B /* Detail */, A44AB20AA6F24D959D7EAC79 /* Create */, AD292C97DAFD4C18A064840D /* Edit */, @@ -2314,6 +2321,16 @@ path = Detail; sourceTree = ""; }; + BC0CMPLT2F3B123400000000 /* Complete */ = { + isa = PBXGroup; + children = ( + BC1CMPLT2F3B123400000001 /* BundleCompleteView.swift */, + BC2CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift */, + BC3CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift */, + ); + path = Complete; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -2815,6 +2832,9 @@ STYVKQEFFZGIGOFGB2L823IG /* BundleViewModel+Views.swift in Sources */, 40WF8CXMLHGD9B5S521VX89Y /* BundleViewModel+Cache.swift in Sources */, AA3908F82EC8BF0400D87EEC /* BundleDetailView+SaveImage.swift in Sources */, + BC4CMPLT2F3B123400000001 /* BundleCompleteView.swift in Sources */, + BC5CMPLT2F3B123400000002 /* BundleCompleteView+VideoGen.swift in Sources */, + BC6CMPLT2F3B123400000003 /* BundleCompleteView+SaveImage.swift in Sources */, 4C4733EB2F22553F005D2376 /* WorkshopView+StickyHeader.swift in Sources */, 4C4733F72F225A2C005D2376 /* WorkshopItemDetailView.swift in Sources */, 4C4733EC2F22553F005D2376 /* WorkshopView+MainContent.swift in Sources */, diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleKeyringCellView.swift b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleKeyringCellView.swift index 86c0c8ed..cadae039 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleKeyringCellView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Shared/BundleKeyringCellView.swift @@ -109,9 +109,9 @@ struct BundleKeyringCellView: View { .overlay( Circle() .strokeBorder(.white100, lineWidth: 1) - .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 0) + .shadow(color: Color.black.opacity(0.25), radius: 2, x: 0, y: 0) ) - .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 0) + .shadow(color: Color.black.opacity(0.25), radius: 2, x: 0, y: 0) } else { Circle() .fill(.clear) @@ -119,9 +119,9 @@ struct BundleKeyringCellView: View { .overlay( Circle() .strokeBorder(.white100, lineWidth: 1) - .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 0) + .shadow(color: Color.black.opacity(0.25), radius: 2, x: 0, y: 0) ) - .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 0) + .shadow(color: Color.black.opacity(0.25), radius: 2, x: 0, y: 0) } } }