diff --git a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift index 3d8b644f2..2ce47c34c 100644 --- a/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift +++ b/Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift @@ -28,7 +28,7 @@ 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: 310), "DuZzonKu": CGSize(width: 376, height: 376) ] diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift index 92d04c553..bfa8e4d28 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/CollectionKeyringDetailView+Sheet.swift @@ -21,7 +21,7 @@ extension CollectionKeyringDetailView { basicInfo // 메모 있으면 - if let memo = keyring.memo, !memo.isEmpty { + if keyring.selectedTemplate == "WishHorse26" || (keyring.memo != nil && !keyring.memo!.isEmpty) { memoSection } @@ -112,6 +112,11 @@ extension CollectionKeyringDetailView { .typography(.notosans13M) .foregroundColor(.mainOpacity70) + Text("·") + .typography(.notosans13M) + .foregroundColor(.mainOpacity70) + .padding(.horizontal, 2) + if let receivedAt = keyring.receivedAt { Text(formattedReceiveDate(date: receivedAt)) .typography(.notosans13M) @@ -122,15 +127,32 @@ extension CollectionKeyringDetailView { } private var basicInfo: some View { - VStack(spacing: 0) { - if keyring.senderId != nil && keyring.receivedAt != nil { + let hasReceiveInfo = keyring.senderId != nil && keyring.receivedAt != nil + let isWishHorse = keyring.selectedTemplate == "WishHorse26" + + return VStack(spacing: 2) { + if hasReceiveInfo { receiveInfo .padding(.top, 10) } + // WishHorse 템플릿일 때 배너 이미지 표시 + if isWishHorse { + Image(.wishHorseBanner) + .padding(.top, 10) + } + Text(keyring.name) .typography(.notosans24M) - .padding(.top, (keyring.senderId != nil && keyring.receivedAt != nil) ? 10 : 30) + .padding(.top, { + if isWishHorse { + return 2 + } else if hasReceiveInfo { + return 10 + } else { + return 20 + } + }()) Text(formattedDate(date: keyring.createdAt)) .typography(.suit14M) @@ -144,12 +166,35 @@ extension CollectionKeyringDetailView { private var memoSection: some View { ZStack { - MemoView(memo: keyring.memo ?? "", sheetDetent: $sheetDetent) + // WishHorse 템플릿일 때 특별 메시지 표시 + if keyring.selectedTemplate == "WishHorse26" { + LockedMemoView() + } else { + MemoView(memo: keyring.memo ?? "", sheetDetent: $sheetDetent) + } } .padding(.top, 15) } + // WishHorse 템플릿용 잠긴 메모 뷰 + private struct LockedMemoView: View { + var body: some View { + Text("작성된 메모는 2027년 1월 1일에 확인할 수 있어요.") + .typography(.notosans14R) + .foregroundColor(.gray400) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .center) + .frame(minHeight: 60) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(.gray100, lineWidth: 1) + ) + } + } + private struct MemoView: View { let memo: String @State private var textHeight: CGFloat = 0 diff --git a/Keychy/Keychy/Presentation/Collection/Views/Detail/KeyringEditView.swift b/Keychy/Keychy/Presentation/Collection/Views/Detail/KeyringEditView.swift index a87498e41..e4df348cc 100644 --- a/Keychy/Keychy/Presentation/Collection/Views/Detail/KeyringEditView.swift +++ b/Keychy/Keychy/Presentation/Collection/Views/Detail/KeyringEditView.swift @@ -51,6 +51,11 @@ struct KeyringEditView: View { return keyring.isEditable && keyring.authorId == currentUserId } + // WishHorse 템플릿일 때는 이름과 메모 수정 불가 + private var canEditName: Bool { + return canEdit && keyring.selectedTemplate != "WishHorse26" + } + enum Field: Hashable { case name case memo @@ -239,7 +244,7 @@ extension KeyringEditView { VStack(spacing: 25) { nameInputField - if canEdit || !(keyring.memo?.isEmpty ?? true) { + if canEdit || !(keyring.memo?.isEmpty ?? true) || keyring.selectedTemplate == "WishHorse26" { memoInputField } } @@ -261,8 +266,13 @@ extension KeyringEditView { .tint(.main500) .submitLabel(.done) .focused($focusedField, equals: .name) - .disabled(!canEdit) + .disabled(!canEditName) .onChange(of: editedName) { newValue in + // WishHorse 템플릿일 때는 수정 불가 + if keyring.selectedTemplate == "WishHorse26" { + return + } + // 글자수 제한만 적용 (특수문자 허용) var sanitized = newValue @@ -301,20 +311,20 @@ extension KeyringEditView { .buttonStyle(PlainButtonStyle()) .padding(.trailing, 16) .padding(.leading, 8) - .opacity(canEdit ? 1 : 0) + .opacity(canEditName ? 1 : 0) } } .frame(height: 52) .background( RoundedRectangle(cornerRadius: 12) - .fill(canEdit ? .gray50 : .white100) + .fill(canEditName ? .gray50 : .white100) ) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(canEdit ? .clear : .gray100, lineWidth: 1) + .stroke(canEditName ? .clear : .gray100, lineWidth: 1) ) .onTapGesture { - if canEdit { + if canEditName { focusedField = .name } } @@ -334,53 +344,65 @@ extension KeyringEditView { .typography(.suit16B) ZStack(alignment: .topLeading) { - // Placeholder - if editedMemo.isEmpty { - Text("메모를 입력해주세요") - .typography(.notosans16R25) - .foregroundColor(.gray300) - .padding(.horizontal, 19) - .padding(.vertical, 18) - .allowsHitTesting(false) - } - - if canEdit { - // 편집 가능 - TextEditor(text: $editedMemo) - .typography(.notosans16R25) - .foregroundColor(.black100) - .scrollContentBackground(.hidden) - .background(Color.clear) - .padding(.horizontal, 14) - .padding(.vertical, 10) + // WishHorse 템플릿일 때 특별 메시지 표시 + if keyring.selectedTemplate == "WishHorse26" { + Text("작성된 메모는 2027년 1월 1일에 확인할 수 있어요.".byCharWrapping) + .typography(.notosans14R) + .foregroundColor(.gray400) + .frame(maxWidth: .infinity, alignment: .center) .scrollIndicators(.hidden) - .focused($focusedField, equals: .memo) - .tint(.main500) + .padding(.horizontal, 16) + .padding(.vertical, 14) } else { - // 편집 불가 : 스크롤만 가능 - ScrollView { - Text(editedMemo.byCharWrapping) + // 일반 메모 입력 UI + // Placeholder + if editedMemo.isEmpty { + Text("메모를 입력해주세요") + .typography(.notosans16R25) + .foregroundColor(.gray300) + .padding(.horizontal, 19) + .padding(.vertical, 18) + .allowsHitTesting(false) + } + + if canEdit { + // 편집 가능 + TextEditor(text: $editedMemo) .typography(.notosans16R25) .foregroundColor(.black100) - .frame(maxWidth: .infinity, alignment: .leading) + .scrollContentBackground(.hidden) + .background(Color.clear) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .scrollIndicators(.hidden) + .focused($focusedField, equals: .memo) + .tint(.main500) + } else { + // 편집 불가 : 스크롤만 가능 + ScrollView { + Text(editedMemo.byCharWrapping) + .typography(.notosans16R25) + .foregroundColor(.black100) + .frame(maxWidth: .infinity, alignment: .leading) + } + .scrollIndicators(.hidden) + .padding(.horizontal, 16) + .padding(.vertical, 14) } - .scrollIndicators(.hidden) - .padding(.horizontal, 16) - .padding(.vertical, 14) } } - .frame(height: 140) + .frame(height: (keyring.selectedTemplate == "WishHorse26") ? 60 : 140) .background( RoundedRectangle(cornerRadius: 12) - .fill(canEdit ? .gray50 : .white100) + .fill(keyring.selectedTemplate == "WishHorse26" ? .white100 : (canEdit ? .gray50 : .white100)) ) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(canEdit ? .clear : .gray100, lineWidth: 1) + .stroke(keyring.selectedTemplate == "WishHorse26" ? .gray100 : (canEdit ? .clear : .gray100), lineWidth: 1) ) .onTapGesture { - if canEdit { + if canEdit && keyring.selectedTemplate != "WishHorse26" { focusedField = .memo } } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Helpers.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Helpers.swift index f1ffe2228..00320da89 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Helpers.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Helpers.swift @@ -23,6 +23,21 @@ extension KeyringInfoInputView { var sceneYOffset: CGFloat { isSheetExpanded ? -120 : 0 } + + /// 다음 버튼 활성화 조건 + var isNextButtonEnabled: Bool { + // 이름이 비어있거나 욕설이 포함되어 있으면 비활성화 + guard !viewModel.nameText.isEmpty && !hasProfanity else { + return false + } + + // WishHorse26 템플릿일 때는 메모도 필수 + if viewModel.templateId == "WishHorse26" { + return !viewModel.memoText.isEmpty + } + + return true + } } // MARK: - KeyringScene Section @@ -72,12 +87,12 @@ extension KeyringInfoInputView { } label: { Text("다음") .typography(.suit17B) - .foregroundStyle(viewModel.nameText.isEmpty || hasProfanity ? .gray300 : .main500) + .foregroundStyle(isNextButtonEnabled ? .main500 : .gray300) .padding(5) } .buttonStyle(.glassProminent) - .tint(viewModel.nameText.isEmpty || hasProfanity ? .clear : .white100) - .allowsHitTesting(!viewModel.nameText.isEmpty && !isSavingToFirebase && !hasProfanity) + .tint(isNextButtonEnabled ? .white100 : .clear) + .allowsHitTesting(isNextButtonEnabled && !isSavingToFirebase) } } } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Sheet.swift b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Sheet.swift index c9314ad65..5d2875353 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Sheet.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Shared/Views/KeyringInfoInputView+Sheet.swift @@ -143,9 +143,17 @@ extension KeyringInfoInputView { extension KeyringInfoInputView { var textMemoView: some View { VStack(alignment: .leading, spacing: 10) { - Text("메모") - .typography(.suit16B) - .foregroundStyle(.black100) + HStack(spacing: 3) { + Text("메모") + .typography(.suit16B) + .foregroundStyle(.black100) + + if viewModel.templateId == "WishHorse26" { + Text("(필수)") + .typography(.suit16B) + .foregroundStyle(.black100) + } + } ZStack(alignment: .topLeading) { TextEditor(text: $viewModel.memoText) @@ -169,18 +177,34 @@ extension KeyringInfoInputView { } if viewModel.memoText.isEmpty { - Text("메모(선택)") - .typography(.notosans15M) - .foregroundColor(.gray300) - .padding(.top, 18) - .padding(.leading, 17) - .allowsHitTesting(false) + HStack(spacing: 2) { + Text("메모") + .typography(.notosans15M) + .foregroundColor(.gray200) + + if viewModel.templateId != "WishHorse26" { + Text("(선택)") + .typography(.notosans15M) + .foregroundColor(.gray200) + } + } + .padding(.top, 18) + .padding(.leading, 17) + .allowsHitTesting(false) } } .background( RoundedRectangle(cornerRadius: 12) .fill(.gray50) ) + + // WishHorse26 전용 안내 문구 + if viewModel.templateId == "WishHorse26" { + Text("[2026년을 말해봐 키링]에 작성한 메모는 수정이 불가해요.\n메모는 2027년 1월 1일에 자동으로 활성화되어 확인할 수 있어요.") + .typography(.suit13M) + .foregroundColor(.main500) + .multilineTextAlignment(.leading) + } } } } diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/DuZzonKu/ViewModels/DuZzonKuVM.swift index 0a0884f13..5e6a8b2f0 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.26 // 프레임 모드는 더 낮은 높이 + return 0.28 // 프레임 모드는 더 낮은 높이 case .effect: return 0.3 // 이펙트 모드도 같은 높이 default: diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+ImageConversion.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+ImageConversion.swift index f72fac25f..a944fffab 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+ImageConversion.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM+ImageConversion.swift @@ -50,14 +50,43 @@ extension WishHorse26VM { } // 프레임 크기 설정 (WishHorse26FramePreviewView와 동일) - let targetFrameHeight: CGFloat = 324 + let targetFrameHeight: CGFloat = 310 let frameAspect = frameImage.size.width / frameImage.size.height let targetFrameWidth = targetFrameHeight * frameAspect let targetFrameSize = CGSize(width: targetFrameWidth, height: targetFrameHeight) + // type "A"인 경우 회전 및 오프셋 적용 + let shouldApplyTransform = frame.type == "A" + + if shouldApplyTransform { + // type "A": 회전과 오프셋이 적용된 이미지 합성 + bodyImage = composeWithTransform( + frameImage: frameImage, + maneImage: maneImage, + saddleImage: saddleImage, + targetFrameSize: targetFrameSize + ) + } else { + // type "B" 또는 기타: 일반 합성 + bodyImage = composeNormal( + frameImage: frameImage, + maneImage: maneImage, + saddleImage: saddleImage, + targetFrameSize: targetFrameSize + ) + } + } + + // MARK: - Normal Composition (type "B") + private func composeNormal( + frameImage: UIImage, + maneImage: UIImage?, + saddleImage: UIImage?, + targetFrameSize: CGSize + ) -> UIImage { let renderer = UIGraphicsImageRenderer(size: targetFrameSize) - let composedImage = renderer.image { context in + return renderer.image { context in // 1. 프레임 이미지 그리기 (배경) frameImage.draw(in: CGRect(origin: .zero, size: targetFrameSize)) @@ -71,12 +100,69 @@ extension WishHorse26VM { saddleImage.draw(in: CGRect(origin: .zero, size: targetFrameSize)) } } + } + + // MARK: - Transform Composition (type "A") + private func composeWithTransform( + frameImage: UIImage, + maneImage: UIImage?, + saddleImage: UIImage?, + targetFrameSize: CGSize + ) -> UIImage { + // 회전 각도 (degree -> radian) + let rotationAngle: CGFloat = 20 * .pi / 180 - bodyImage = composedImage + let xOffset: CGFloat = 29.58 + let yOffset: CGFloat = 40 + + // 캔버스 크기: 높이를 충분히 늘림 (아래쪽 여유 공간 확보) + let extraHeight: CGFloat = 60 + let canvasSize = CGSize( + width: targetFrameSize.width, + height: targetFrameSize.height + extraHeight + ) + + let renderer = UIGraphicsImageRenderer(size: canvasSize) + + return renderer.image { context in + let cgContext = context.cgContext + + // 원본 크기의 중앙 기준점 (캔버스 위쪽에 배치) + let centerX = targetFrameSize.width / 2 + 4 + let centerY = targetFrameSize.height / 2 + 3 + + cgContext.translateBy(x: centerX, y: centerY) + + // 회전 적용 + cgContext.rotate(by: rotationAngle) + + // 오프셋 적용 + cgContext.translateBy(x: xOffset, y: yOffset) + + // 이미지들을 원본 크기 그대로 그리기 + let drawRect = CGRect( + x: -targetFrameSize.width / 2, + y: -targetFrameSize.height / 2, + width: targetFrameSize.width, + height: targetFrameSize.height + ) + + // 1. 프레임 이미지 + frameImage.draw(in: drawRect) + + // 2. 갈기 이미지 + if let maneImage = maneImage { + maneImage.draw(in: drawRect) + } + + // 3. 안장 이미지 + if let saddleImage = saddleImage { + saddleImage.draw(in: drawRect) + } + } } // MARK: - Helper: Download Frame Image - /// Nuke를 사용하여 프레임 이미지 다운로드 private func downloadImage(from url: URL) async -> UIImage? { // Bundle에서 먼저 확인 (로컬 이미지인 경우) diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift index 03e8ea8d0..f4aa8aa2a 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/ViewModels/WishHorse26VM.swift @@ -109,7 +109,7 @@ class WishHorse26VM: KeyringViewModelProtocol { func bottomViewHeightRatio(for mode: CustomizingMode) -> CGFloat { switch mode { case .frame: - return 0.4 // 프레임 모드는 더 낮은 높이 + return 0.42 // 프레임 모드는 더 낮은 높이 case .effect: return 0.3 // 이펙트 모드도 같은 높이 default: diff --git a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift index 8e9941027..21c63a0a4 100644 --- a/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift +++ b/Keychy/Keychy/Presentation/KeyringMaker/Templates/WishHorse26/Views/WishHorse26FramePreviewView.swift @@ -16,6 +16,15 @@ struct WishHorse26FramePreviewView: View { // 크기 설정 private let targetFrameHeight: CGFloat = 269 + + // MARK: - 프레임 타입에 따른 변환 여부 + private var shouldApplyTransform: Bool { + guard let frame = viewModel.selectedFrame else { + return false + } + // type이 "A"인 경우 변환 적용 + return frame.type == "A" + } var body: some View { GeometryReader { geometry in @@ -26,7 +35,7 @@ struct WishHorse26FramePreviewView: View { // 프레임 + 안장 + 갈기 합성 영역 VStack { Spacer() - .frame(height: 95) + .frame(height: 115) compositionView } @@ -36,7 +45,7 @@ struct WishHorse26FramePreviewView: View { .resizable() .scaledToFit() .frame(width: 90) - .offset(y: -31) + .offset(y: 5) } Spacer() @@ -109,6 +118,12 @@ struct WishHorse26FramePreviewView: View { } } } + // MARK: - type "A"일 때만 변환 적용 + .rotationEffect(.degrees(shouldApplyTransform ? 20 : 0)) + .offset( + x: shouldApplyTransform ? 15.39 : -0.1, + y: shouldApplyTransform ? 37.34 : 0 + ) .onAppear { isFrameLoaded = true } diff --git a/Keychy/Keychy/Resources/Assets.xcassets/03. Template/wishHorseBanner.imageset/Contents.json b/Keychy/Keychy/Resources/Assets.xcassets/03. Template/wishHorseBanner.imageset/Contents.json new file mode 100644 index 000000000..624583b5f --- /dev/null +++ b/Keychy/Keychy/Resources/Assets.xcassets/03. Template/wishHorseBanner.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "wishHorseBanner.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Keychy/Keychy/Resources/Assets.xcassets/03. Template/wishHorseBanner.imageset/wishHorseBanner.pdf b/Keychy/Keychy/Resources/Assets.xcassets/03. Template/wishHorseBanner.imageset/wishHorseBanner.pdf new file mode 100644 index 000000000..38cac8a34 Binary files /dev/null and b/Keychy/Keychy/Resources/Assets.xcassets/03. Template/wishHorseBanner.imageset/wishHorseBanner.pdf differ