diff --git a/Keychy/Keychy.xcodeproj/project.pbxproj b/Keychy/Keychy.xcodeproj/project.pbxproj index fd987f4c0..dc9b5cc57 100644 --- a/Keychy/Keychy.xcodeproj/project.pbxproj +++ b/Keychy/Keychy.xcodeproj/project.pbxproj @@ -103,6 +103,9 @@ 4C004FB52F18D98C00D9063E /* ReviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C004FB42F18D98C00D9063E /* ReviewManager.swift */; }; 4C004FBB2F19F1FE00D9063E /* RootViewModel+ReviewCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C004FBA2F19F1FE00D9063E /* RootViewModel+ReviewCheck.swift */; }; 4C07024C2ECF10760026D6DC /* EffectSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C07024A2ECF10760026D6DC /* EffectSyncManager.swift */; }; + 4C25259C2F303745003CC5AD /* WidgetBundleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */; }; + 4C25259D2F3037CD003CC5AD /* WidgetBundleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */; }; + 4C25259E2F3037D6003CC5AD /* BundleImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B5707E2EC206CD0049F969 /* BundleImageCache.swift */; }; 4C3687F72EBFA87800C64E75 /* Pretendard-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */; }; 4C3687FA2EBFC0FB00C64E75 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */; }; 4C3687FC2EC05E6800C64E75 /* AccountAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */; }; @@ -552,6 +555,7 @@ 4C004FB42F18D98C00D9063E /* ReviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewManager.swift; sourceTree = ""; }; 4C004FBA2F19F1FE00D9063E /* RootViewModel+ReviewCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewModel+ReviewCheck.swift"; sourceTree = ""; }; 4C07024A2ECF10760026D6DC /* EffectSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EffectSyncManager.swift; sourceTree = ""; }; + 4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBundleModel.swift; sourceTree = ""; }; 4C3687F62EBFA87800C64E75 /* Pretendard-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pretendard-Medium.ttf"; sourceTree = ""; }; 4C3687F82EBFC0FB00C64E75 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 4C3687FB2EC05E6800C64E75 /* AccountAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountAlert.swift; sourceTree = ""; }; @@ -1727,6 +1731,7 @@ isa = PBXGroup; children = ( 4CEC61D82EAE08C00099ECEE /* KeyringBundle.swift */, + 4C25259B2F303745003CC5AD /* WidgetBundleModel.swift */, AA9B2E882EB001AA0004D31C /* Background.swift */, AA9B2E8A2EB001B70004D31C /* Carabiner.swift */, AA0A54B62EC053E4007B5413 /* CarabinerType.swift */, @@ -2678,6 +2683,7 @@ 4C7775322EB0EEF100981C3E /* ItemDetailImage.swift in Sources */, AA2146B12F15D43C0048D40E /* BundleEditView+Alert.swift in Sources */, 4C07024C2ECF10760026D6DC /* EffectSyncManager.swift in Sources */, + 4C25259C2F303745003CC5AD /* WidgetBundleModel.swift in Sources */, 3828F54B2EC4D0C500F1B040 /* CollectionView+Handlers.swift in Sources */, 4CF2A96A2F0B94EA00BA9FDA /* View+PullToRefresh.swift in Sources */, 4C86A61F2F29E52D0023AA2D /* PurchaseHistoryView.swift in Sources */, @@ -2779,7 +2785,9 @@ files = ( C6B56F702EC08ED40049F969 /* WidgetKeyring.swift in Sources */, C6B56F612EC08CCE0049F969 /* KeyringImageCache.swift in Sources */, + 4C25259E2F3037D6003CC5AD /* BundleImageCache.swift in Sources */, 4CC8D0192EF0395F00317467 /* AppIntent.swift in Sources */, + 4C25259D2F3037CD003CC5AD /* WidgetBundleModel.swift in Sources */, 4CC8D01A2EF0395F00317467 /* WidgetConfiguration.mm in Sources */, 4CC8D01B2EF0395F00317467 /* WidgetKeychy.swift in Sources */, 4CC8D01C2EF0395F00317467 /* WidgetKeychyBundle.swift in Sources */, diff --git a/Keychy/Keychy/CommonModels/KeyringBundle/WidgetBundleModel.swift b/Keychy/Keychy/CommonModels/KeyringBundle/WidgetBundleModel.swift new file mode 100644 index 000000000..6e3af554f --- /dev/null +++ b/Keychy/Keychy/CommonModels/KeyringBundle/WidgetBundleModel.swift @@ -0,0 +1,32 @@ +// +// WidgetBundleModel.swift +// Keychy +// +// Created by 길지훈 on 2/2/26. +// + +import Foundation + +/// 위젯에서 사용할 뭉치 메타데이터 +struct WidgetBundleModel: Codable, Identifiable, Hashable { + let id: String // Firestore documentId + let name: String // 뭉치 이름 + let imagePath: String // App Group 내 이미지 경로 + let createdAt: Date // 생성일 (위젯 목록 정렬용) + + // 기존 데이터 호환용 (createdAt 없는 경우 .distantPast로 처리) + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + imagePath = try container.decode(String.self, forKey: .imagePath) + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? .distantPast + } + + init(id: String, name: String, imagePath: String, createdAt: Date) { + self.id = id + self.name = name + self.imagePath = imagePath + self.createdAt = createdAt + } +} diff --git a/Keychy/Keychy/Core/Cache/BundleImageCache.swift b/Keychy/Keychy/Core/Cache/BundleImageCache.swift index 17a82634e..b5ccbac8b 100644 --- a/Keychy/Keychy/Core/Cache/BundleImageCache.swift +++ b/Keychy/Keychy/Core/Cache/BundleImageCache.swift @@ -7,18 +7,44 @@ import Foundation import SwiftUI +import WidgetKit -/// 번들(MultiKeyring) 썸네일 이미지를 FileManager 기반으로 캐싱 (앱 샌드박스) +/// 번들(MultiKeyring) 썸네일 이미지를 FileManager 기반으로 캐싱 (App Group 사용) class BundleImageCache { static let shared = BundleImageCache() + // MARK: - 이미지 타입 정의 + enum ImageType { + case full // 배경 포함 (앱용) + case widget // 배경 없음 (위젯용) + + var suffix: String { + switch self { + case .full: return "" + case .widget: return "_widget" + } + } + } + private let fileManager = FileManager.default + private let appGroupIdentifier = "group.keychy.app" private let metadataFileName = "available_bundles.json" + private let widgetKind = "WidgetKeychy" + + /// App Group Container URL + private var containerURL: URL? { + fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) + } - /// 캐시 디렉토리 경로 (앱 샌드박스) + /// 캐시 디렉토리 경로 (App Group) private var cacheDirectory: URL { - let urls = fileManager.urls(for: .cachesDirectory, in: .userDomainMask) - let bundleCache = urls[0].appendingPathComponent("BundleThumbnails", isDirectory: true) + guard let container = containerURL else { + // Fallback to local cache if App Group is not available + let urls = fileManager.urls(for: .cachesDirectory, in: .userDomainMask) + return urls[0].appendingPathComponent("BundleThumbnails", isDirectory: true) + } + + let bundleCache = container.appendingPathComponent("BundleThumbnails", isDirectory: true) // 디렉토리가 없으면 생성 if !fileManager.fileExists(atPath: bundleCache.path) { @@ -33,8 +59,8 @@ class BundleImageCache { } /// 메타데이터 파일 URL - private var metadataFileURL: URL { - cacheDirectory.appendingPathComponent(metadataFileName) + private var metadataFileURL: URL? { + containerURL?.appendingPathComponent(metadataFileName) } private init() { @@ -44,12 +70,13 @@ class BundleImageCache { // MARK: - 저장 /// PNG 데이터를 파일로 저장 - func save(pngData: Data, for bundleID: String) { - let fileURL = cacheDirectory.appendingPathComponent("\(bundleID).png") + func save(pngData: Data, for bundleID: String, type: ImageType = .full) { + let fileName = "\(bundleID)\(type.suffix).png" + let fileURL = cacheDirectory.appendingPathComponent(fileName) do { try pngData.write(to: fileURL) - + } catch { print("❌ [BundleCache] 저장 실패: \(bundleID) - \(error.localizedDescription)") } @@ -58,8 +85,9 @@ class BundleImageCache { // MARK: - 불러오기 /// 캐시된 PNG 데이터 로드 - func load(for bundleID: String) -> Data? { - let fileURL = cacheDirectory.appendingPathComponent("\(bundleID).png") + func load(for bundleID: String, type: ImageType = .full) -> Data? { + let fileName = "\(bundleID)\(type.suffix).png" + let fileURL = cacheDirectory.appendingPathComponent(fileName) guard fileManager.fileExists(atPath: fileURL.path) else { return nil @@ -77,8 +105,9 @@ class BundleImageCache { // MARK: - 삭제 /// 특정 번들 캐시 삭제 - func delete(for bundleID: String) { - let fileURL = cacheDirectory.appendingPathComponent("\(bundleID).png") + func delete(for bundleID: String, type: ImageType = .full) { + let fileName = "\(bundleID)\(type.suffix).png" + let fileURL = cacheDirectory.appendingPathComponent(fileName) guard fileManager.fileExists(atPath: fileURL.path) else { return @@ -91,6 +120,12 @@ class BundleImageCache { } } + /// 특정 번들의 모든 타입 캐시 삭제 + func deleteAll(for bundleID: String) { + delete(for: bundleID, type: .full) + delete(for: bundleID, type: .widget) + } + // MARK: - 전체 캐시 삭제 /// 모든 캐시 파일 삭제 @@ -104,41 +139,70 @@ class BundleImageCache { } catch { print("❌ [BundleCache] 전체 캐시 삭제 실패: \(error.localizedDescription)") } + + // 메타데이터 파일 삭제 + clearMetadata() + + // 위젯 타임라인 새로고침 + reloadWidgets() + } + + /// 메타데이터 파일 삭제 + func clearMetadata() { + guard let fileURL = metadataFileURL else { return } + + if fileManager.fileExists(atPath: fileURL.path) { + do { + try fileManager.removeItem(at: fileURL) + } catch { + print("❌ [BundleCache] 메타데이터 삭제 실패: \(error.localizedDescription)") + } + } } // MARK: - 캐시 존재 여부 /// 캐시 파일이 존재하는지 확인 - func exists(for bundleID: String) -> Bool { - let fileURL = cacheDirectory.appendingPathComponent("\(bundleID).png") + func exists(for bundleID: String, type: ImageType = .full) -> Bool { + let fileName = "\(bundleID)\(type.suffix).png" + let fileURL = cacheDirectory.appendingPathComponent(fileName) return fileManager.fileExists(atPath: fileURL.path) } - // MARK: - 메타데이터 관리 + // MARK: - 위젯 메타데이터 관리 + + /// 위젯용 뭉치 목록 저장 + func saveWidgetBundleModels(_ bundles: [WidgetBundleModel]) { + guard let fileURL = metadataFileURL else { + print("❌ [BundleCache] 메타데이터 파일 URL을 찾을 수 없습니다.") + return + } - /// 사용 가능한 번들 목록 저장 - func saveAvailableBundles(_ bundles: [AvailableBundle]) { do { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted let data = try encoder.encode(bundles) - try data.write(to: metadataFileURL, options: .atomic) + try data.write(to: fileURL, options: .atomic) } catch { print("❌ [BundleCache] 메타데이터 저장 실패: \(error.localizedDescription)") } } - /// 사용 가능한 번들 목록 로드 - func loadAvailableBundles() -> [AvailableBundle] { - guard fileManager.fileExists(atPath: metadataFileURL.path) else { - print("⚠️ [BundleCache] 메타데이터 파일이 없습니다.") + /// 위젯용 뭉치 목록 로드 + func loadWidgetBundleModels() -> [WidgetBundleModel] { + guard let fileURL = metadataFileURL else { + print("❌ [BundleCache] 메타데이터 파일 URL을 찾을 수 없습니다.") + return [] + } + + guard fileManager.fileExists(atPath: fileURL.path) else { return [] } do { - let data = try Data(contentsOf: metadataFileURL) + let data = try Data(contentsOf: fileURL) let decoder = JSONDecoder() - let bundles = try decoder.decode([AvailableBundle].self, from: data) + let bundles = try decoder.decode([WidgetBundleModel].self, from: data) return bundles } catch { print("❌ [BundleCache] 메타데이터 로드 실패: \(error.localizedDescription)") @@ -148,39 +212,58 @@ class BundleImageCache { // MARK: - 동기화 메서드 - /// 번들 추가 또는 업데이트 (이미지 + 메타데이터) - func syncBundle(id: String, name: String, imageData: Data) { + /// 뭉치 추가 또는 업데이트 (이미지 + 메타데이터) + /// - Parameters: + /// - id: 뭉치 ID + /// - name: 뭉치 이름 + /// - fullImageData: 배경 포함 이미지 (앱용) + /// - widgetImageData: 배경 없는 이미지 (위젯용, optional) + /// - createdAt: 생성일 + func syncBundle(id: String, name: String, fullImageData: Data, widgetImageData: Data? = nil, createdAt: Date = Date()) { // 1. 이미지 저장 - save(pngData: imageData, for: id) + save(pngData: fullImageData, for: id, type: .full) + + // 위젯용 이미지가 있으면 저장 + if let widgetData = widgetImageData { + save(pngData: widgetData, for: id, type: .widget) + } // 2. 메타데이터 업데이트 - var bundles = loadAvailableBundles() - let imagePath = "\(id).png" + var bundles = loadWidgetBundleModels() + // 위젯용 이미지가 있으면 _widget.png, 없으면 .png 사용 + let imagePath = widgetImageData != nil ? "\(id)_widget.png" : "\(id).png" if let index = bundles.firstIndex(where: { $0.id == id }) { - // 기존 번들 업데이트 - bundles[index] = AvailableBundle(id: id, name: name, imagePath: imagePath) + // 기존 뭉치 업데이트 + bundles[index] = WidgetBundleModel(id: id, name: name, imagePath: imagePath, createdAt: createdAt) } else { - // 새 번들 추가 - bundles.append(AvailableBundle(id: id, name: name, imagePath: imagePath)) + // 새 뭉치 추가 + bundles.append(WidgetBundleModel(id: id, name: name, imagePath: imagePath, createdAt: createdAt)) } - saveAvailableBundles(bundles) + saveWidgetBundleModels(bundles) + + // 3. 위젯 타임라인 새로고침 + reloadWidgets() } - /// 번들 삭제 (이미지 + 메타데이터) + /// 뭉치 삭제 (이미지 + 메타데이터) func removeBundle(id: String) { - // 1. 이미지 삭제 - delete(for: id) + // 1. 이미지 삭제 (full + widget 모두) + deleteAll(for: id) // 2. 메타데이터에서 제거 - var bundles = loadAvailableBundles() + var bundles = loadWidgetBundleModels() bundles.removeAll { $0.id == id } - saveAvailableBundles(bundles) + saveWidgetBundleModels(bundles) + + print("✅ [BundleCache] 뭉치 완전 삭제: \(id)") + // 3. 위젯 타임라인 새로고침 + reloadWidgets() } - /// 이미지 경로로 이미지 로드 + /// 이미지 경로로 이미지 로드 (위젯용) func loadImageByPath(_ imagePath: String) -> Data? { let fileURL = cacheDirectory.appendingPathComponent(imagePath) @@ -197,6 +280,12 @@ class BundleImageCache { } } + // MARK: - 위젯 업데이트 + + private func reloadWidgets() { + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) + } + // MARK: - 캐시 정보 /// 전체 캐시 파일 개수 및 용량 반환 @@ -247,11 +336,32 @@ class BundleImageCache { print("❌ [BundleCache] 파일 목록 조회 실패: \(error.localizedDescription)") } } + + // MARK: - 하위 호환성 (기존 AvailableBundle 지원) + + /// 기존 saveAvailableBundles 호환 (deprecated) + @available(*, deprecated, message: "Use syncBundle(id:name:fullImageData:widgetImageData:createdAt:) instead") + func saveAvailableBundles(_ bundles: [AvailableBundle]) { + // AvailableBundle -> WidgetBundleModel 변환 + let widgetBundles = bundles.map { + WidgetBundleModel(id: $0.id, name: $0.name, imagePath: $0.imagePath, createdAt: .distantPast) + } + saveWidgetBundleModels(widgetBundles) + } + + /// 기존 loadAvailableBundles 호환 (deprecated) + @available(*, deprecated, message: "Use loadWidgetBundleModels() instead") + func loadAvailableBundles() -> [AvailableBundle] { + let widgetBundles = loadWidgetBundleModels() + return widgetBundles.map { + AvailableBundle(id: $0.id, name: $0.name, imagePath: $0.imagePath) + } + } } -/// 번들 메타데이터 구조체 +/// 기존 번들 메타데이터 구조체 (하위 호환용) struct AvailableBundle: Codable, Identifiable, Hashable { let id: String // Firestore documentId let name: String // 번들 이름 - let imagePath: String // 앱 샌드박스 내 이미지 경로 + let imagePath: String // App Group 내 이미지 경로 } diff --git a/Keychy/Keychy/Core/Cache/KeyringImageCache.swift b/Keychy/Keychy/Core/Cache/KeyringImageCache.swift index d381810d2..595976717 100644 --- a/Keychy/Keychy/Core/Cache/KeyringImageCache.swift +++ b/Keychy/Keychy/Core/Cache/KeyringImageCache.swift @@ -17,18 +17,21 @@ class KeyringImageCache { enum ImageType { case thumbnail // 175*233 (보관함용) case gift // 304*490 (선물/알림용) - + case widget // 350*467 (위젯용 - 더 큼) + var suffix: String { switch self { case .thumbnail: return "_thumb" case .gift: return "_gift" + case .widget: return "_widget" } } - + var size: CGSize { switch self { case .thumbnail: return CGSize(width: 175, height: 233) case .gift: return CGSize(width: 304, height: 490) + case .widget: return CGSize(width: 350, height: 467) } } } @@ -130,6 +133,7 @@ class KeyringImageCache { func deleteAll(for keyringID: String) { delete(for: keyringID, type: .thumbnail) delete(for: keyringID, type: .gift) + delete(for: keyringID, type: .widget) } // MARK: - 전체 캐시 삭제 @@ -281,12 +285,17 @@ class KeyringImageCache { /// 키링 추가 또는 업데이트 (이미지 + 메타데이터) func syncKeyring(id: String, name: String, imageData: Data, createdAt: Date) { - // 1. 이미지 저장 + // 1. 이미지 저장 (썸네일) save(pngData: imageData, for: id, type: .thumbnail) - // 2. 메타데이터 업데이트 + // 2. 위젯용 이미지 저장 (더 큰 사이즈) + if let widgetData = resizeImageData(imageData, to: ImageType.widget.size) { + save(pngData: widgetData, for: id, type: .widget) + } + + // 3. 메타데이터 업데이트 (위젯용 이미지 경로 사용) var keyrings = loadWidgetKeyrings() - let imagePath = "\(id)_thumb.png" + let imagePath = "\(id)_widget.png" if let index = keyrings.firstIndex(where: { $0.id == id }) { // 기존 키링 업데이트 @@ -298,14 +307,27 @@ class KeyringImageCache { saveWidgetKeyrings(keyrings) - // 3. 위젯 타임라인 새로고침 + // 4. 위젯 타임라인 새로고침 reloadWidgets() } + /// 이미지 리사이즈 + private func resizeImageData(_ data: Data, to size: CGSize) -> Data? { + guard let image = UIImage(data: data) else { return nil } + + UIGraphicsBeginImageContextWithOptions(size, false, 1.0) + image.draw(in: CGRect(origin: .zero, size: size)) + let resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return resizedImage?.pngData() + } + /// 키링 삭제 (이미지 + 메타데이터) func removeKeyring(id: String) { - // 1. 이미지 삭제 + // 1. 이미지 삭제 (썸네일 + 위젯) delete(for: id, type: .thumbnail) + delete(for: id, type: .widget) // 2. 메타데이터에서 제거 var keyrings = loadWidgetKeyrings() diff --git a/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringCaptureScene+Capture.swift b/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringCaptureScene+Capture.swift index 7eebe6960..7d32504c5 100644 --- a/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringCaptureScene+Capture.swift +++ b/Keychy/Keychy/Core/Components/KeyringBundle/MultiKeyringCaptureScene+Capture.swift @@ -51,23 +51,27 @@ extension MultiKeyringCaptureScene { /// 번들 이미지 캡처 /// - Parameters: /// - keyringDataList: 키링 데이터 리스트 - /// - backgroundImageURL: 배경 이미지 URL + /// - backgroundImageURL: 배경 이미지 URL (nil이면 배경 없이 캡처) /// - carabinerBackImageURL: 카라비너 뒷면 이미지 URL (hamburger 타입) /// - carabinerFrontImageURL: 카라비너 앞면 이미지 URL (hamburger 타입) /// - carabinerType: 카라비너 타입 /// - carabinerX: 카라비너 왼쪽 상단 X 좌표 /// - carabinerY: 카라비너 왼쪽 상단 Y 좌표 /// - carabinerWidth: 카라비너 너비 + /// - trimTransparentEdges: 투명 여백 제거 여부 (위젯용) + /// - customCaptureSize: 커스텀 캡처 사이즈 (nil이면 기본값 사용) /// - Returns: 캡처된 PNG 데이터 static func captureBundleImage( keyringDataList: [MultiKeyringCaptureScene.KeyringData], - backgroundImageURL: String, + backgroundImageURL: String? = nil, carabinerBackImageURL: String? = nil, carabinerFrontImageURL: String? = nil, carabinerType: CarabinerType? = nil, carabinerX: CGFloat = 0, carabinerY: CGFloat = 0, carabinerWidth: CGFloat = 0, + trimTransparentEdges: Bool = false, + customCaptureSize: CGSize? = nil ) async -> Data? { do { try await preloadAllImages( @@ -80,9 +84,9 @@ extension MultiKeyringCaptureScene { } catch { return nil } - - // 고정 캡처 사이즈 (iPhone 16 Pro 기준) - let captureSize = CGSize(width: 402, height: 874) + + // 캡처 사이즈 (커스텀 또는 기본값) + let captureSize = customCaptureSize ?? CGSize(width: 402, height: 874) return await withCheckedContinuation { continuation in @@ -131,12 +135,111 @@ extension MultiKeyringCaptureScene { } // PNG 캡처 - let pngData = await scene.captureToPNG() + var pngData = await scene.captureToPNG() + + // 투명 여백 제거 (위젯용) + if trimTransparentEdges, let data = pngData { + pngData = trimTransparentEdgesFromPNG(data) + } continuation.resume(returning: pngData) } } } + + // MARK: - Image Trimming + + /// PNG 이미지에서 투명 여백 제거 + private static func trimTransparentEdgesFromPNG(_ pngData: Data) -> Data? { + guard let uiImage = UIImage(data: pngData), + let cgImage = uiImage.cgImage else { + return pngData + } + + let width = cgImage.width + let height = cgImage.height + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return pngData + } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + guard let pixelData = context.data else { + return pngData + } + + let data = pixelData.bindMemory(to: UInt8.self, capacity: width * height * 4) + + // 불투명 영역 경계 찾기 + var minX = width, minY = height, maxX = 0, maxY = 0 + + for y in 0.. 0 { + minX = min(minX, x) + minY = min(minY, y) + maxX = max(maxX, x) + maxY = max(maxY, y) + } + } + } + + // 유효한 영역이 없으면 원본 반환 + guard minX < maxX && minY < maxY else { + return pngData + } + + // 약간의 패딩 추가 (10px) + let padding = 10 + minX = max(0, minX - padding) + minY = max(0, minY - padding) + maxX = min(width - 1, maxX + padding) + maxY = min(height - 1, maxY + padding) + + let cropRect = CGRect( + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1 + ) + + guard let croppedCGImage = cgImage.cropping(to: cropRect) else { + return pngData + } + + let croppedImage = UIImage(cgImage: croppedCGImage) + + // 위젯에 적합한 크기로 리사이즈 (최대 500px) + let maxSize: CGFloat = 500 + let croppedWidth = croppedImage.size.width + let croppedHeight = croppedImage.size.height + + if croppedWidth <= maxSize && croppedHeight <= maxSize { + return croppedImage.pngData() ?? pngData + } + + let scale = min(maxSize / croppedWidth, maxSize / croppedHeight) + let newSize = CGSize(width: croppedWidth * scale, height: croppedHeight * scale) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + croppedImage.draw(in: CGRect(origin: .zero, size: newSize)) + let resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return resizedImage?.pngData() ?? croppedImage.pngData() ?? pngData + } // MARK: - Image Preloading (Cache Warming) @@ -144,13 +247,15 @@ extension MultiKeyringCaptureScene { /// - 캡쳐 전에 호출하여 Scene 내부에서의 이미지 로딩 실패를 방지 private static func preloadAllImages( keyringDataList: [MultiKeyringCaptureScene.KeyringData], - backgroundURL: String, + backgroundURL: String?, carabinerBackURL: String?, carabinerFrontURL: String?, carabinerType: CarabinerType? ) async throws { - // 1. 배경 이미지 로드 - _ = try await StorageManager.shared.getImage(path: backgroundURL) + // 1. 배경 이미지 로드 (있는 경우에만) + if let bgURL = backgroundURL { + _ = try await StorageManager.shared.getImage(path: bgURL) + } // 2. 모든 키링 bodyImage 병렬 로드 try await withThrowingTaskGroup(of: Void.self) { group in diff --git a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Image.swift b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Image.swift index 790a56bc6..805c07eb8 100644 --- a/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Image.swift +++ b/Keychy/Keychy/Presentation/Bundle/ViewModels/BundleViewModel+Image.swift @@ -90,9 +90,16 @@ extension BundleViewModel { } /// 뷰모델에 저장된 뭉치 이미지를 BundleImageCache에 저장 + /// - Parameters: + /// - bundleId: 뭉치 ID + /// - bundleName: 뭉치 이름 + /// - widgetImageData: 위젯용 이미지 (배경 없음, optional) + /// - createdAt: 뭉치 생성일 (새 뭉치는 Date(), 기존 뭉치는 bundle.createdAt) func saveBundleImageToCache( bundleId: String, - bundleName: String + bundleName: String, + widgetImageData: Data? = nil, + createdAt: Date = Date() ) { guard let imageData = bundleCapturedImage else { return @@ -100,7 +107,9 @@ extension BundleViewModel { BundleImageCache.shared.syncBundle( id: bundleId, name: bundleName, - imageData: imageData + fullImageData: imageData, + widgetImageData: widgetImageData, + createdAt: createdAt ) } } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+SaveImage.swift b/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+SaveImage.swift index da95e5585..275a38bc0 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+SaveImage.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/BundleDetailView+SaveImage.swift @@ -125,7 +125,8 @@ extension BundleDetailView { carabinerFrontURL = nil } - guard let pngData = await MultiKeyringCaptureScene.captureBundleImage( + // 1. 배경 포함 캡쳐 (앱용) + guard let fullImageData = await MultiKeyringCaptureScene.captureBundleImage( keyringDataList: keyringDataList, backgroundImageURL: bg.backgroundImage, carabinerBackImageURL: carabinerBackURL, @@ -140,24 +141,40 @@ extension BundleDetailView { } return } - + // viewModel에 캡쳐된 이미지 저장 await MainActor.run { - bundleVM.bundleCapturedImage = pngData + bundleVM.bundleCapturedImage = fullImageData } - + + // 캐시가 없는 경우에만 복구 (위젯용 포함) if let documentId = bundle.documentId, !BundleImageCache.shared.exists(for: documentId) { + // 2. 배경 없이 캡쳐 (위젯용 - 투명 여백 제거 후 리사이즈) + let widgetImageData = await MultiKeyringCaptureScene.captureBundleImage( + keyringDataList: keyringDataList, + backgroundImageURL: nil, + carabinerBackImageURL: carabinerBackURL, + carabinerFrontImageURL: carabinerFrontURL, + carabinerType: carabinerType, + carabinerX: cb.carabinerX, + carabinerY: cb.carabinerY, + carabinerWidth: cb.carabinerWidth, + trimTransparentEdges: true + ) + BundleImageCache.shared.syncBundle( id: documentId, name: bundle.name, - imageData: pngData + fullImageData: fullImageData, + widgetImageData: widgetImageData, + createdAt: bundle.createdAt ) print("[BundleDetailView] 편집된 뭉치 캐시 복구: \(documentId)") } // PNG 데이터를 UIImage로 변환하여 포토 라이브러리에 저장 - guard let image = UIImage(data: pngData) else { + guard let image = UIImage(data: fullImageData) else { await MainActor.run { uiState.isCapturing = false } diff --git a/Keychy/Keychy/Presentation/Bundle/Views/BundleEditView.swift b/Keychy/Keychy/Presentation/Bundle/Views/BundleEditView.swift index 12b802037..aca99ffe9 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/BundleEditView.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/BundleEditView.swift @@ -274,7 +274,7 @@ struct BundleEditView: View { } // MARK: - 썸네일 재캡쳐 & 캐시 저장 - private func recaptureAndCacheBundleThumbnail(bundleId: String, bundleName: String) async { + private func recaptureAndCacheBundleThumbnail(bundleId: String, bundleName: String, createdAt: Date) async { // 편집 중 상태로 캡쳐 guard let bg = bundleVM.newSelectedBackground?.background, let cb = bundleVM.newSelectedCarabiner?.carabiner else { @@ -308,8 +308,8 @@ struct BundleEditView: View { carabinerFrontURL = nil } - // 캡쳐 - if let pngData = await MultiKeyringCaptureScene.captureBundleImage( + // 1. 배경 포함 캡쳐 (앱용) + guard let fullImageData = await MultiKeyringCaptureScene.captureBundleImage( keyringDataList: captureKeyrings, backgroundImageURL: bg.backgroundImage, carabinerBackImageURL: carabinerBackURL, @@ -318,21 +318,38 @@ struct BundleEditView: View { carabinerX: cb.carabinerX, carabinerY: cb.carabinerY, carabinerWidth: cb.carabinerWidth - ) { - // 캐시 저장 - BundleImageCache.shared.syncBundle( - id: bundleId, - name: bundleName, - imageData: pngData - ) - await MainActor.run { - bundleVM.bundleCapturedImage = pngData - isCapturing = false - } - } else { + ) else { await MainActor.run { isCapturing = false } + return + } + + // 2. 배경 없이 캡쳐 (위젯용 - 투명 여백 제거 후 리사이즈) + let widgetImageData = await MultiKeyringCaptureScene.captureBundleImage( + keyringDataList: captureKeyrings, + backgroundImageURL: nil, + carabinerBackImageURL: carabinerBackURL, + carabinerFrontImageURL: carabinerFrontURL, + carabinerType: carabinerType, + carabinerX: cb.carabinerX, + carabinerY: cb.carabinerY, + carabinerWidth: cb.carabinerWidth, + trimTransparentEdges: true + ) + + // 캐시 저장 (full + widget) + BundleImageCache.shared.syncBundle( + id: bundleId, + name: bundleName, + fullImageData: fullImageData, + widgetImageData: widgetImageData, + createdAt: createdAt + ) + + await MainActor.run { + bundleVM.bundleCapturedImage = fullImageData + isCapturing = false } } } @@ -386,7 +403,7 @@ extension BundleEditView { // 저장 후 썸네일 재캡쳐, 캐시 저장 if let bundle = bundleVM.selectedBundle, let documentId = bundleVM.selectedBundle?.documentId { - await recaptureAndCacheBundleThumbnail(bundleId: documentId, bundleName: bundle.name) + await recaptureAndCacheBundleThumbnail(bundleId: documentId, bundleName: bundle.name, createdAt: bundle.createdAt) } await MainActor.run { diff --git a/Keychy/Keychy/Presentation/Bundle/Views/Component/KeyringBundleItem.swift b/Keychy/Keychy/Presentation/Bundle/Views/Component/KeyringBundleItem.swift index 55eeb3cbb..38a1914a6 100644 --- a/Keychy/Keychy/Presentation/Bundle/Views/Component/KeyringBundleItem.swift +++ b/Keychy/Keychy/Presentation/Bundle/Views/Component/KeyringBundleItem.swift @@ -83,11 +83,18 @@ extension KeyringBundleItem { guard let documentId = bundle.documentId else { return } - + // 캐시에서 이미지 로드 if let imageData = BundleImageCache.shared.load(for: documentId), let uiImage = UIImage(data: imageData) { cachedImage = Image(uiImage: uiImage) + + // 위젯용 이미지가 없으면 재캡처 (위젯 지원용) + if !BundleImageCache.shared.exists(for: documentId, type: .widget) { + Task { + await recaptureAndCacheBundleImage(bundleId: documentId, bundleName: bundle.name) + } + } } else { // 캐시가 없으면 다시 캡처 Task { @@ -159,7 +166,8 @@ extension KeyringBundleItem { carabinerFrontURL = nil } - guard let pngData = await MultiKeyringCaptureScene.captureBundleImage( + // 1. 배경 포함 캡쳐 (앱용) + guard let fullImageData = await MultiKeyringCaptureScene.captureBundleImage( keyringDataList: keyringDataList, backgroundImageURL: background.backgroundImage, carabinerBackImageURL: carabinerBackURL, @@ -174,16 +182,31 @@ extension KeyringBundleItem { } return } - - // BundleImageCache에 저장 + + // 2. 배경 없이 캡쳐 (위젯용 - 투명 여백 제거 후 리사이즈) + let widgetImageData = await MultiKeyringCaptureScene.captureBundleImage( + keyringDataList: keyringDataList, + backgroundImageURL: nil, + carabinerBackImageURL: carabinerBackURL, + carabinerFrontImageURL: carabinerFrontURL, + carabinerType: carabinerType, + carabinerX: carabiner.carabinerX, + carabinerY: carabiner.carabinerY, + carabinerWidth: carabiner.carabinerWidth, + trimTransparentEdges: true + ) + + // BundleImageCache에 저장 (full + widget) BundleImageCache.shared.syncBundle( id: bundleId, name: bundleName, - imageData: pngData + fullImageData: fullImageData, + widgetImageData: widgetImageData, + createdAt: bundle.createdAt ) - + // UI 업데이트 - if let uiImage = UIImage(data: pngData) { + if let uiImage = UIImage(data: fullImageData) { await MainActor.run { cachedImage = Image(uiImage: uiImage) isCapturing = false diff --git a/Keychy/WidgetKeychy/AppIntent.swift b/Keychy/WidgetKeychy/AppIntent.swift index 45292b860..44ea7c381 100644 --- a/Keychy/WidgetKeychy/AppIntent.swift +++ b/Keychy/WidgetKeychy/AppIntent.swift @@ -2,7 +2,7 @@ // AppIntent.swift // WidgetKeychy // -// 위젯 키링 선택 Intent +// 위젯 키링/뭉치 선택 Intent // import WidgetKit @@ -48,13 +48,56 @@ struct KeyringEntityQuery: EntityQuery { } } +// MARK: - Bundle Entity + +/// 위젯에서 선택 가능한 뭉치 +struct BundleEntity: AppEntity { + let id: String + let name: String + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "뭉치" + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } + + static var defaultQuery = BundleEntityQuery() +} + +// MARK: - Bundle Entity Query + +/// App Group에서 뭉치 목록 조회 +struct BundleEntityQuery: EntityQuery { + /// ID로 뭉치 찾기 (위젯 복원 시 사용) + func entities(for identifiers: [String]) async throws -> [BundleEntity] { + let widgetBundles = BundleImageCache.shared.loadWidgetBundleModels() + return widgetBundles + .filter { identifiers.contains($0.id) } + .map { BundleEntity(id: $0.id, name: $0.name) } + } + + /// 선택 가능한 뭉치 목록 (최신순) + func suggestedEntities() async throws -> [BundleEntity] { + let widgetBundles = BundleImageCache.shared.loadWidgetBundleModels() + .sorted { $0.createdAt > $1.createdAt } + return widgetBundles.map { BundleEntity(id: $0.id, name: $0.name) } + } + + func defaultResult() async -> BundleEntity? { + nil + } +} + // MARK: - Selection Intent -/// 위젯 키링 선택 인텐트 +/// 위젯 키링/뭉치 선택 인텐트 struct KeyringSelectionIntent: WidgetConfigurationIntent { - static var title: LocalizedStringResource { "키링 선택" } - static var description: IntentDescription { "위젯에 표시할 키링을 선택하세요" } + static var title: LocalizedStringResource { "키링/뭉치 선택" } + static var description: IntentDescription { "위젯에 표시할 키링 또는 뭉치를 선택하세요" } @Parameter(title: "키링") var selectedKeyring: KeyringEntity? + + @Parameter(title: "뭉치") + var selectedBundle: BundleEntity? } diff --git a/Keychy/WidgetKeychy/WidgetKeychy.swift b/Keychy/WidgetKeychy/WidgetKeychy.swift index fd2f23063..85fb56ad0 100644 --- a/Keychy/WidgetKeychy/WidgetKeychy.swift +++ b/Keychy/WidgetKeychy/WidgetKeychy.swift @@ -7,6 +7,7 @@ import WidgetKit import SwiftUI +import UIKit // MARK: - Timeline Provider @@ -43,7 +44,7 @@ struct WidgetKeychy: Widget { .containerBackground(.clear, for: .widget) } .configurationDisplayName("Keychy 위젯") - .description("위젯에 표시될 키링을 골라주세요") + .description("위젯에 표시될 키링 또는 뭉치를 골라주세요") .contentMarginsDisabled() .supportedFamilies([.systemSmall, .systemLarge]) } @@ -56,17 +57,58 @@ struct KeyringWidgetEntryView: View { @Environment(\.widgetFamily) var widgetFamily var body: some View { - if let keyring = entry.configuration.selectedKeyring, - let imageData = KeyringImageCache.shared.loadImageByPath("\(keyring.id)_thumb.png"), - let uiImage = UIImage(data: imageData) { + // 뭉치가 선택된 경우 뭉치 표시 + if let bundle = entry.configuration.selectedBundle, + let uiImage = loadBundleImage(bundleId: bundle.id) { Image(uiImage: uiImage) .resizable() .scaledToFit() - } else { + .scaleEffect(0.85) // 뭉치 약간 작게 + } + // 키링이 선택된 경우 키링 표시 + else if let keyring = entry.configuration.selectedKeyring, + let uiImage = loadKeyringImage(keyringId: keyring.id) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + // 키링 기본 크기 + } + // 아무것도 선택되지 않은 경우 placeholder + else { placeholderView } } + /// 뭉치 이미지 로드 (위젯용 우선, 없으면 full 버전 사용) + private func loadBundleImage(bundleId: String) -> UIImage? { + // 1. 위젯용 이미지 시도 (_widget.png) + if let imageData = BundleImageCache.shared.loadImageByPath("\(bundleId)_widget.png"), + let uiImage = UIImage(data: imageData) { + return uiImage + } + // 2. Fallback: full 이미지 (.png) + if let imageData = BundleImageCache.shared.loadImageByPath("\(bundleId).png"), + let uiImage = UIImage(data: imageData) { + return uiImage + } + return nil + } + + /// 키링 이미지 로드 (위젯용 우선, 없으면 썸네일 사용) + private func loadKeyringImage(keyringId: String) -> UIImage? { + // 1. 위젯용 이미지 시도 (_widget.png) + if let imageData = KeyringImageCache.shared.loadImageByPath("\(keyringId)_widget.png"), + let uiImage = UIImage(data: imageData) { + return uiImage + } + // 2. Fallback: 썸네일 이미지 (_thumb.png) + if let imageData = KeyringImageCache.shared.loadImageByPath("\(keyringId)_thumb.png"), + let uiImage = UIImage(data: imageData) { + return uiImage + } + return nil + } + @ViewBuilder private var placeholderView: some View { if widgetFamily == .systemSmall {