Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Keychy/Keychy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@
4C6622462EAF7D63001760B5 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 4C6622452EAF7D63001760B5 /* LICENSE */; };
4C6622482EAF9B3A001760B5 /* Haptic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6622472EAF9B3A001760B5 /* Haptic.swift */; };
4C7775322EB0EEF100981C3E /* ItemDetailImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7775312EB0EEF100981C3E /* ItemDetailImage.swift */; };
AA0213F12F0E000000000001 /* TemplateImageSlideshow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0213F02F0E000000000001 /* TemplateImageSlideshow.swift */; };
4C7775342EB0EF8800981C3E /* ItemDetailInfoSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7775332EB0EF8800981C3E /* ItemDetailInfoSection.swift */; };
4C7775382EB0EFD800981C3E /* ItemDetailMakingBtn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7775372EB0EFD800981C3E /* ItemDetailMakingBtn.swift */; };
4C77753E2EB1343600981C3E /* IntroViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7775392EB1343600981C3E /* IntroViewModel.swift */; };
Expand Down Expand Up @@ -758,6 +759,7 @@
4C6622452EAF7D63001760B5 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
4C6622472EAF9B3A001760B5 /* Haptic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptic.swift; sourceTree = "<group>"; };
4C7775312EB0EEF100981C3E /* ItemDetailImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailImage.swift; sourceTree = "<group>"; };
AA0213F02F0E000000000001 /* TemplateImageSlideshow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateImageSlideshow.swift; sourceTree = "<group>"; };
4C7775332EB0EF8800981C3E /* ItemDetailInfoSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailInfoSection.swift; sourceTree = "<group>"; };
4C7775372EB0EFD800981C3E /* ItemDetailMakingBtn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailMakingBtn.swift; sourceTree = "<group>"; };
4C7775392EB1343600981C3E /* IntroViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1806,6 +1808,7 @@
4C7775312EB0EEF100981C3E /* ItemDetailImage.swift */,
4C7775332EB0EF8800981C3E /* ItemDetailInfoSection.swift */,
4C7775372EB0EFD800981C3E /* ItemDetailMakingBtn.swift */,
AA0213F02F0E000000000001 /* TemplateImageSlideshow.swift */,
);
path = Items;
sourceTree = "<group>";
Expand Down Expand Up @@ -2928,6 +2931,7 @@
AAEB46AD2EC1C893002B13E5 /* BundleMenu.swift in Sources */,
4CF2A96D2F0F969300BA9FDA /* UpdateAlert.swift in Sources */,
4C7775322EB0EEF100981C3E /* ItemDetailImage.swift in Sources */,
AA0213F12F0E000000000001 /* TemplateImageSlideshow.swift in Sources */,
AA2146B12F15D43C0048D40E /* BundleEditView+Alert.swift in Sources */,
4C07024C2ECF10760026D6DC /* EffectSyncManager.swift in Sources */,
4C25259C2F303745003CC5AD /* WidgetBundleModel.swift in Sources */,
Expand Down
44 changes: 42 additions & 2 deletions Keychy/Keychy/CommonModels/Template/KeyringTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import FirebaseFirestore
/// Firebase Firestore에서 가져오는 키링 템플릿 모델
/// - Collection: templates/{templateId}
/// - 사용자별 구매/소유 상태는 별도로 관리 (users/{userId}/purchasedTemplates)
struct KeyringTemplate: Identifiable, Codable, Equatable, Hashable {
struct KeyringTemplate: Identifiable, Equatable, Hashable {
/// Document ID
@DocumentID var id: String?

Expand All @@ -31,6 +31,11 @@ struct KeyringTemplate: Identifiable, Codable, Equatable, Hashable {
/// 프리뷰 URL -----> 만들기 preview에 띄울 이미지
let previewURL: String

/// 프리뷰 슬라이드 이미지 URL 배열 (최대 5장)
/// - Firestore에 [String] 배열로 저장
/// - 비어있으면 기존 thumbnailURL/previewURL로 fallback
let previewImages: [String]

/// 가이드 이미지 URL -----> 만들기 시작 전 가이드 화면에 띄울 이미지
let guidingImageURL: String

Expand Down Expand Up @@ -60,7 +65,7 @@ struct KeyringTemplate: Identifiable, Codable, Equatable, Hashable {
/// - 양수: 바디 중심에서 위로 이동 (구멍이 더 위에 있음)
/// - 음수: 바디 중심에서 아래로 이동 (구멍이 더 아래에 있음)
let hookOffsetY: CGFloat?

let chainLength: Int?

/// 무료 템플릿 여부
Expand All @@ -69,6 +74,40 @@ struct KeyringTemplate: Identifiable, Codable, Equatable, Hashable {
}
}

// MARK: - Codable (previewImages fallback 처리)
extension KeyringTemplate: Codable {
enum CodingKeys: String, CodingKey {
case id, templateName, description, interactions
case thumbnailURL, previewURL, previewImages
case guidingImageURL, guidingText, tags, price
case downloadCount, useCount, createdAt, isActive
case hookOffsetY, chainLength
}

/// Firestore에 previewImages 필드가 없는 기존 문서도 안전하게 디코딩
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
_id = try container.decode(DocumentID<String>.self, forKey: .id)
templateName = try container.decode(String.self, forKey: .templateName)
description = try container.decode(String.self, forKey: .description)
interactions = try container.decode([String].self, forKey: .interactions)
thumbnailURL = try container.decode(String.self, forKey: .thumbnailURL)
previewURL = try container.decode(String.self, forKey: .previewURL)
// 기존 문서에 필드 없으면 빈 배열로 fallback
previewImages = (try? container.decode([String].self, forKey: .previewImages)) ?? []
guidingImageURL = try container.decode(String.self, forKey: .guidingImageURL)
guidingText = try container.decode(String.self, forKey: .guidingText)
tags = try container.decode([String].self, forKey: .tags)
price = try container.decodeIfPresent(Int.self, forKey: .price)
downloadCount = try container.decode(Int.self, forKey: .downloadCount)
useCount = try container.decode(Int.self, forKey: .useCount)
createdAt = try container.decode(Date.self, forKey: .createdAt)
isActive = try container.decode(Bool.self, forKey: .isActive)
hookOffsetY = try container.decodeIfPresent(CGFloat.self, forKey: .hookOffsetY)
chainLength = try container.decodeIfPresent(Int.self, forKey: .chainLength)
}
}

// MARK: - Preview용 Mock Data
extension KeyringTemplate {
/// Firestore의 AcrylicPhoto 템플릿 데이터
Expand All @@ -79,6 +118,7 @@ extension KeyringTemplate {
interactions: ["tap", "swing"],
thumbnailURL: "",
previewURL: "https://firebasestorage.googleapis.com/v0/b/keychy-f6011.firebasestorage.app/o/Templates%2FacrylicPhoto%2FacrylicPreview.png?alt=media&token=cc1e53cf-9de2-4a32-a50f-f02339999f24",
previewImages: [],
guidingImageURL: "https://firebasestorage.googleapis.com/v0/b/keychy-f6011.firebasestorage.app/o/Templates%2FacrylicPhoto%2FguidingImage.png?alt=media&token=example",
guidingText: "인물 사진을 선택해주세요\n배경이 제거된 키링을 만들 수 있습니다",
tags: ["이미지형"],
Expand Down
107 changes: 107 additions & 0 deletions Keychy/Keychy/Core/Components/View/Items/TemplateImageSlideshow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// TemplateImageSlideshow.swift
// Keychy
//
// Created by 길지훈 on 2026/02/13.
//

import SwiftUI
import NukeUI
import Nuke

/// 템플릿 프리뷰 이미지를 1초 간격으로 자동 순환하는 슬라이드 컴포넌트
///
/// - `localFirstImageName`이 있으면 번들 이미지를 즉시 표시 + 나머지만 네트워크 다운로드
/// - 번들 이미지가 없으면 전체를 네트워크에서 병렬 다운로드
/// - `.task` 기반 async 루프로 뷰 lifecycle에 바인딩 (사라지면 자동 cancel)
struct TemplateImageSlideshow: View {
let imageURLs: [String]
/// 앱 번들에 포함된 첫 번째 프리뷰 이미지 이름 (ex. "preview_AcrylicPhoto")
var localFirstImageName: String? = nil

@State private var currentIndex = 0
@State private var loadedImages: [UIImage] = []

/// 번들에서 찾은 첫 번째 이미지
private var bundleFirstImage: UIImage? {
guard let name = localFirstImageName else { return nil }
return UIImage(named: name)
}

var body: some View {
Group {
if !loadedImages.isEmpty {
Image(uiImage: loadedImages[currentIndex])
.resizable()
.aspectRatio(contentMode: .fit)
} else if let bundleImage = bundleFirstImage {
// 번들 이미지 즉시 표시 (네트워크 로딩 중)
Image(uiImage: bundleImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else if let firstURL = imageURLs.first, let url = URL(string: firstURL) {
// 번들 없음 → 첫 번째 URL을 LazyImage로 표시
LazyImage(url: url) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Color.gray50
}
}
} else {
Color.gray50
}
}
.task {
await preloadAllImages()

guard loadedImages.count > 1 else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
guard !Task.isCancelled else { return }
currentIndex = (currentIndex + 1) % loadedImages.count
}
}
}

/// 이미지를 UIImage 배열로 프리로드
/// - 번들 이미지가 있으면: [번들이미지] + 네트워크[1...] (0번 스킵)
/// - 번들 이미지가 없으면: 네트워크[0...] 전체 다운로드
private func preloadAllImages() async {
guard loadedImages.isEmpty else { return }

let pipeline = ImagePipeline.shared
let hasBundleFirst = bundleFirstImage != nil

// 번들 이미지가 있으면 1번부터, 없으면 0번부터 다운로드
let urlsToDownload = hasBundleFirst
? Array(imageURLs.dropFirst())
: imageURLs

let downloaded = await withTaskGroup(of: (Int, UIImage?).self) { group in
for (index, urlString) in urlsToDownload.enumerated() {
guard let url = URL(string: urlString) else { continue }
group.addTask {
let image = try? await pipeline.image(for: url)
return (index, image)
}
}

var indexed: [(Int, UIImage)] = []
for await (index, image) in group {
if let image { indexed.append((index, image)) }
}
return indexed.sorted { $0.0 < $1.0 }.map(\.1)
}

// 번들 이미지를 0번에 넣고 나머지 이어붙이기
if hasBundleFirst, let first = bundleFirstImage {
loadedImages = [first] + downloaded
} else {
guard !downloaded.isEmpty else { return }
loadedImages = downloaded
}
}
}
7 changes: 5 additions & 2 deletions Keychy/Keychy/Core/Keyring/Scene/KeyringScale.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ enum KeyringScale {

// MARK: - 카라비너별 뭉치 키링 스케일
private static let carabinerScales: [String: CGFloat] = [
"basic": 0.65
// TODO: 추가 카라비너 스케일
"CarouselOrgel": 0.5,
"HeartPepero": 0.6,
"HeartPlanet": 0.6,
"MeltingWhiteChoco": 0.7,
"UfoCat": 0.5,
]

// MARK: - 기본값
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,22 @@ extension TemplatePreviewBody {
private var templatePreview: some View {
VStack {
Spacer()

if let template {
ItemDetailImage(itemURL: template.previewURL)
.scaledToFit()
.frame(width: 386, height: 386)
if template.previewImages.count > 1 {
// 슬라이드 이미지
TemplateImageSlideshow(
imageURLs: template.previewImages,
localFirstImageName: "preview_\(template.id ?? "")"
)
.scaledToFit()
.frame(width: 386, height: 386)
} else {
// fallback: 기존 단일 프리뷰 이미지
ItemDetailImage(itemURL: template.previewURL)
.scaledToFit()
.frame(width: 386, height: 386)
}
} else {
LoadingAlert(type: .short40, message: nil)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ struct WorkshopItemCard<Item: WorkshopItem>: View {
.padding(.horizontal, 5)
.frame(width: twoGridCellWidth, height: itemHeight)
.clipped()
} else if let template = item as? KeyringTemplate, template.previewImages.count > 1 {
// 슬라이드 이미지가 있는 키링 템플릿
TemplateImageSlideshow(
imageURLs: template.previewImages,
localFirstImageName: "preview_\(template.id ?? "")"
)
.padding(.vertical, 10)
.clipped()
.frame(width: twoGridCellWidth, height: itemHeight)
} else {
// Sound, Background, Carabiner, 키링 등은 기존처럼 이미지로 처리 (GIF 지원)
SimpleAnimatedImage(url: item.thumbnailURL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ private struct RecentTemplateCard: View {
var body: some View {
Button(action: onTap) {
ZStack {
LazyImage(url: URL(string: template.thumbnailURL)) { state in
LazyImage(url: URL(string: template.previewImages.first ?? template.thumbnailURL)) { state in
if let image = state.image {
image
.resizable()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,23 @@ struct WorkshopTemplateSelectSheet: View {
VStack(spacing: 8) {
// 썸네일 + 가격 오버레이 (공방 스타일)
ZStack(alignment: .top) {
SimpleAnimatedImage(url: template.thumbnailURL)
.aspectRatio(contentMode: .fit)
.padding(.vertical, 10)
.clipped()
.frame(width: 105, height: 140.61)
if template.previewImages.count > 1 {
// 슬라이드 이미지
TemplateImageSlideshow(
imageURLs: template.previewImages,
localFirstImageName: "preview_\(template.id ?? "")"
)
.padding(.vertical, 10)
.clipped()
.frame(width: 105, height: 140.61)
} else {
// fallback: 기존 단일 이미지
SimpleAnimatedImage(url: template.thumbnailURL)
.aspectRatio(contentMode: .fit)
.padding(.vertical, 10)
.clipped()
.frame(width: 105, height: 140.61)
}

// 유료/보유 오버레이
VStack {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "preview_ DuZzonKu.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "preview_ PixelKeyring.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "preview_ Polaroid.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading