From 8cba26b84b569d24673eaba69cd0be509e75f1b8 Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sat, 25 Jul 2026 14:39:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[#54]=20feat:=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=B7=B0=20placeholder=20=EB=AA=A8=EB=8D=B8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ServiceLogos/Contents.json | 6 ++ .../github.imageset/Contents.json | 16 +++++ .../ServiceLogos/github.imageset/github.svg | 1 + .../google.imageset/Contents.json | 16 +++++ .../ServiceLogos/google.imageset/google.svg | 1 + .../kakaotalk.imageset/Contents.json | 16 +++++ .../kakaotalk.imageset/kakaotalk.svg | 1 + .../ServiceLogos/naver.imageset/Contents.json | 16 +++++ .../ServiceLogos/naver.imageset/naver.svg | 1 + .../Sources/DVVaultContainerPreviewView.swift | 23 ++++--- .../Sources/Components/DVVaultContainer.swift | 53 +++++++++++++++- .../ServiceLogo/ServiceLogoCatalog.swift | 51 ++++++++++++++++ .../Sources/Dependencies/SecretClient.swift | 61 ++++++++++++++++++- .../Model/CreatableSecretType.swift | 15 +---- .../Features/SecretList/SecretListView.swift | 2 + .../Sources/Support/SecretType+Icon.swift | 23 +++++++ 16 files changed, 276 insertions(+), 26 deletions(-) create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/Contents.json create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/github.imageset/Contents.json create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/github.imageset/github.svg create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/google.imageset/Contents.json create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/google.imageset/google.svg create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/Contents.json create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/kakaotalk.svg create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/naver.imageset/Contents.json create mode 100644 Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/naver.imageset/naver.svg create mode 100644 Projects/DVDesign/Sources/Foundations/ServiceLogo/ServiceLogoCatalog.swift create mode 100644 Projects/DVPresentation/Sources/Support/SecretType+Icon.swift diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/Contents.json b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/github.imageset/Contents.json b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/github.imageset/Contents.json new file mode 100644 index 00000000..58cccfd3 --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/github.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "github.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/github.imageset/github.svg b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/github.imageset/github.svg new file mode 100644 index 00000000..23349765 --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/github.imageset/github.svg @@ -0,0 +1 @@ +GitHub diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/google.imageset/Contents.json b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/google.imageset/Contents.json new file mode 100644 index 00000000..18bfb84f --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/google.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "google.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/google.imageset/google.svg b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/google.imageset/google.svg new file mode 100644 index 00000000..75543e0c --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/google.imageset/google.svg @@ -0,0 +1 @@ +Google diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/Contents.json b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/Contents.json new file mode 100644 index 00000000..713df417 --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "kakaotalk.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/kakaotalk.svg b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/kakaotalk.svg new file mode 100644 index 00000000..b9dd9b4e --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/kakaotalk.svg @@ -0,0 +1 @@ +KakaoTalk diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/naver.imageset/Contents.json b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/naver.imageset/Contents.json new file mode 100644 index 00000000..f1283617 --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/naver.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "naver.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/naver.imageset/naver.svg b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/naver.imageset/naver.svg new file mode 100644 index 00000000..a977bf67 --- /dev/null +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/naver.imageset/naver.svg @@ -0,0 +1 @@ +Naver diff --git a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift index 27310648..ec43e0c5 100644 --- a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift +++ b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift @@ -7,15 +7,19 @@ struct DVVaultContainerPreviewView: View { @State private var selectedIndex: Int? = nil - private let vaults: [(String, String, DVVaultContainer.TrailingIcon?)] = [ - ("내가 설정한 이름", "2026.04.01", nil), - ("이름이름이름", "2026.04.01", nil), - ("Example", "2026.03.27", nil), - ("LongLongNameExampleVault","2026.04.01", nil), - ("만료 임박 항목", "2026.04.01", .expiringSoon), - ("이름이름이름", "2026.04.01", .expiringSoon), - ("만료된 항목", "2026.03.27", .expired), - ("LongLongNameExampleVault","2025.04.01", .expired), + private let vaults: [(String, String, DVVaultContainer.TrailingIcon?, String?)] = [ + ("내가 설정한 이름", "2026.04.01", nil, nil), + ("이름이름이름", "2026.04.01", nil, nil), + ("Example", "2026.03.27", nil, nil), + ("LongLongNameExampleVault","2026.04.01", nil, nil), + ("Google 계정", "2026.04.01", nil, "google"), + ("GitHub 계정", "2026.04.01", nil, "github"), + ("네이버 계정", "2026.04.01", nil, "naver"), + ("카카오톡 계정", "2026.04.01", nil, "kakaotalk"), + ("만료 임박 항목", "2026.04.01", .expiringSoon, "google"), + ("이름이름이름", "2026.04.01", .expiringSoon, nil), + ("만료된 항목", "2026.03.27", .expired, "github"), + ("LongLongNameExampleVault","2025.04.01", .expired, nil), ] var body: some View { @@ -60,6 +64,7 @@ extension DVVaultContainerPreviewView { DVVaultContainer( name: vaults[index].0, date: vaults[index].1, + service: vaults[index].3, trailingIcon: vaults[index].2, isSelected: selectedIndex == index ) diff --git a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift index 9ee02416..3336b617 100644 --- a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift @@ -17,6 +17,9 @@ public struct DVVaultContainer: View { public let name: String public let date: String + public let service: String? + /// `service`가 비어 있을 때만 쓰이는 최종 폴백. 호출부가 자신의 타입 분류에 맞는 SF Symbol 등을 넘긴다. + public let typeIcon: Image? public let trailingIcon: TrailingIcon? public let isSelected: Bool @@ -25,11 +28,15 @@ public struct DVVaultContainer: View { public init( name: String, date: String, + service: String? = nil, + typeIcon: Image? = nil, trailingIcon: TrailingIcon? = nil, isSelected: Bool = false ) { self.name = name self.date = date + self.service = service + self.typeIcon = typeIcon self.trailingIcon = trailingIcon self.isSelected = isSelected } @@ -52,10 +59,50 @@ public struct DVVaultContainer: View { extension DVVaultContainer { + @ViewBuilder private var avatarCircle: some View { - Circle() - .fill(Color.dv(.gray300)) - .frame(width: 44, height: 44) + if let logo = ServiceLogoCatalog.logo(forService: service) { + Circle() + .fill(logo.brandColor) + .frame(width: 44, height: 44) + .overlay( + Image(logo.assetName, bundle: .module) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundStyle(Color.dv(.white)) + ) + } else if let serviceInitial { + Circle() + .fill(Color.dv(.gray300)) + .frame(width: 44, height: 44) + .overlay( + Text(serviceInitial) + .dvFont(.headingLG) + .foregroundStyle(Color.dv(.gray600)) + ) + } else { + Circle() + .fill(Color.dv(.gray300)) + .frame(width: 44, height: 44) + .overlay { + if let typeIcon { + typeIcon + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.dv(.gray600)) + } + } + } + } + + /// 로고 매칭에 실패했지만 `service` 자체는 채워져 있을 때 쓰는 중간 폴백. + private var serviceInitial: String? { + guard let service else { return nil } + guard let first = service.trimmingCharacters(in: .whitespacesAndNewlines).first else { return nil } + return String(first).uppercased() } private var textStack: some View { diff --git a/Projects/DVDesign/Sources/Foundations/ServiceLogo/ServiceLogoCatalog.swift b/Projects/DVDesign/Sources/Foundations/ServiceLogo/ServiceLogoCatalog.swift new file mode 100644 index 00000000..d4ada4b8 --- /dev/null +++ b/Projects/DVDesign/Sources/Foundations/ServiceLogo/ServiceLogoCatalog.swift @@ -0,0 +1,51 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +public struct DVServiceLogo { + public let assetName: String + public let brandColor: Color +} + +/// `Secret.service` 자유 텍스트를 정규화해 번들된 브랜드 로고를 찾는다. +/// 매칭되는 항목이 없으면 `nil`을 반환하며, 호출부는 이니셜 플레이스홀더로 폴백해야 한다. +public enum ServiceLogoCatalog { + + public static func logo(forService service: String?) -> DVServiceLogo? { + guard let service else { return nil } + guard let canonical = aliases[normalize(service)] else { return nil } + return entries[canonical] + } + + private static func normalize(_ raw: String) -> String { + raw.lowercased().filter { $0.isLetter || $0.isNumber } + } + + // 서비스별로 흔히 쓰이는 표기(별칭)를 대표 키로 매핑한다. + private static let aliases: [String: String] = [ + "google": "google", + "gmail": "google", + "googleworkspace": "google", + "github": "github", + "naver": "naver", + "kakao": "kakaotalk", + "kakaotalk": "kakaotalk" + ] + + private static let entries: [String: DVServiceLogo] = [ + "google": DVServiceLogo(assetName: "google", brandColor: Color(hex: 0x4285F4)), + "github": DVServiceLogo(assetName: "github", brandColor: Color(hex: 0x181717)), + "naver": DVServiceLogo(assetName: "naver", brandColor: Color(hex: 0x03C75A)), + "kakaotalk": DVServiceLogo(assetName: "kakaotalk", brandColor: Color(hex: 0xFFCD00)) + ] +} + +private extension Color { + init(hex: UInt32) { + self.init( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255 + ) + } +} diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index 1c61f104..57fe1690 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -28,7 +28,18 @@ extension SecretClient: TestDependencyKey { public static let testValue = SecretClient() public static let previewValue = SecretClient( - fetchByQuery: { _ in [.preview] }, + fetchByQuery: { query in + switch query.collection { + case .expired(let referenceDate): + return [Secret].preview.filter { + $0.deletedAt == nil && ($0.expiresAt.map { $0 < referenceDate } ?? false) + } + case .deleted: + return [Secret].preview.filter { $0.deletedAt != nil } + case .all, .liked, .project: + return [Secret].preview.filter { $0.deletedAt == nil } + } + }, softDelete: { _ in .preview }, restore: { _ in .preview }, permanentlyDelete: { _ in }, @@ -54,12 +65,60 @@ extension Secret { id: UUID(), name: "GitHub API Key", secretType: .apiKeyToken, + service: "github", createdAt: .now, updatedAt: .now, payload: SecretPayload(encryptedData: Data(), keyTag: "preview", schemaVersion: 1) ) } +public extension [Secret] { + /// 아바타 폴백 3단계(로고 매칭 → service 첫 글자 → secretType 아이콘)를 + /// 타입 6종에 걸쳐 전부 확인하기 위한 프리뷰 픽스처. + static let preview: [Secret] = [ + // 1) 로고 매칭 + .preview, // GitHub API Key · service: "github" + secret(name: "Google 계정", type: .oauth, service: "google"), + secret(name: "네이버 계정", type: .oauth, service: "naver"), + secret(name: "카카오톡 계정", type: .oauth, service: "kakaotalk"), + + // 2) 매칭 실패, service 첫 글자로 폴백 + secret(name: "Stripe API Key", type: .apiKeyToken, service: "Stripe"), + secret(name: "CheerLot DB", type: .database, service: "CheerLot"), + + // 3) service 없음, secretType 아이콘으로 폴백 + secret(name: "이름 없는 API 키", type: .apiKeyToken, expiresAt: .now.addingTimeInterval(-86_400)), + secret(name: "사내 SSO", type: .oauth), + secret(name: "운영 DB", type: .database, expiresAt: .now.addingTimeInterval(3 * 86_400)), + secret(name: "배포 서버 SSH 키", type: .sshAndCredentials, expiresAt: .now.addingTimeInterval(20 * 86_400)), + secret(name: ".env 모음", type: .environmentVariableSet), + secret(name: "라이선스 키", type: .etc), + + // 4) Deleted 컬렉션 전용 + secret(name: "삭제된 API 키", type: .apiKeyToken, deletedAt: .now), + ] + + private static func secret( + name: String, + type: SecretType, + service: String? = nil, + expiresAt: Date? = nil, + deletedAt: Date? = nil + ) -> Secret { + Secret( + id: UUID(), + name: name, + secretType: type, + service: service, + expiresAt: expiresAt, + deletedAt: deletedAt, + createdAt: .now, + updatedAt: .now, + payload: SecretPayload(encryptedData: Data(), keyTag: "preview", schemaVersion: 1) + ) + } +} + public extension [Project] { static let preview: [Project] = [ Project(id: UUID(), name: "CheerLot", createdAt: .now, updatedAt: .now), diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreatableSecretType.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreatableSecretType.swift index 4d35646b..081d2349 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreatableSecretType.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreatableSecretType.swift @@ -48,19 +48,8 @@ enum CreatableSecretType: String, CaseIterable, Hashable { } } - /// 타입 선택 화면의 카드 아이콘. + /// 타입 선택 화면의 카드 아이콘. SecretList 아바타 폴백과 동일한 소스(`SecretType.icon`)를 쓴다. var icon: Image { - Image(systemName: iconSystemName) - } - - private var iconSystemName: String { - switch self { - case .apiKeyToken: return "key.fill" - case .oauth: return "person.badge.key.fill" - case .database: return "cylinder.split.1x2.fill" - case .sshAndCredentials: return "terminal.fill" - case .environmentVariableSet: return "curlybraces" - case .etc: return "ellipsis.circle.fill" - } + domainType.icon } } diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index e3864272..fd7fb112 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -109,6 +109,8 @@ extension SecretListView { DVVaultContainer( name: secret.name, date: SecretDateFormatter.string(from: secret.updatedAt), + service: secret.service, + typeIcon: secret.secretType.icon, trailingIcon: trailingIcon(for: secret), isSelected: secret.id == store.selectedSecretID ) diff --git a/Projects/DVPresentation/Sources/Support/SecretType+Icon.swift b/Projects/DVPresentation/Sources/Support/SecretType+Icon.swift new file mode 100644 index 00000000..25404d28 --- /dev/null +++ b/Projects/DVPresentation/Sources/Support/SecretType+Icon.swift @@ -0,0 +1,23 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +import DVDomain + +/// 타입 선택 화면(SelectSecretType)과 목록 화면(SecretList) 아바타 폴백이 공유하는 단일 아이콘 소스. +extension SecretType { + var icon: Image { + Image(systemName: iconSystemName) + } + + private var iconSystemName: String { + switch self { + case .apiKeyToken: return "key.fill" + case .oauth: return "checkmark.shield.fill" + case .database: return "cylinder.split.1x2.fill" + case .sshAndCredentials: return "terminal.fill" + case .environmentVariableSet: return "curlybraces" + case .etc: return "ellipsis.circle.fill" + } + } +} From 668816bc8e37f1eb268b38272616c9e6deb2ce65 Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sat, 25 Jul 2026 15:00:14 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[#54]=20update:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/DVVaultContainerPreviewView.swift | 30 ++++---- .../Sources/Dependencies/SecretClient.swift | 68 ++++++++++++------- 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift index ec43e0c5..772e4f57 100644 --- a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift +++ b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift @@ -7,19 +7,22 @@ struct DVVaultContainerPreviewView: View { @State private var selectedIndex: Int? = nil - private let vaults: [(String, String, DVVaultContainer.TrailingIcon?, String?)] = [ - ("내가 설정한 이름", "2026.04.01", nil, nil), - ("이름이름이름", "2026.04.01", nil, nil), - ("Example", "2026.03.27", nil, nil), - ("LongLongNameExampleVault","2026.04.01", nil, nil), - ("Google 계정", "2026.04.01", nil, "google"), - ("GitHub 계정", "2026.04.01", nil, "github"), - ("네이버 계정", "2026.04.01", nil, "naver"), - ("카카오톡 계정", "2026.04.01", nil, "kakaotalk"), - ("만료 임박 항목", "2026.04.01", .expiringSoon, "google"), - ("이름이름이름", "2026.04.01", .expiringSoon, nil), - ("만료된 항목", "2026.03.27", .expired, "github"), - ("LongLongNameExampleVault","2025.04.01", .expired, nil), + /// `typeIcon`은 DVDesign이 알 필요 없는 실제 secretType과 무관한 값이라, 폴백 렌더링 확인용으로 하나만 재사용한다. + private let placeholderTypeIcon = Image(systemName: "key.fill") + + private let vaults: [(String, String, DVVaultContainer.TrailingIcon?, String?, Bool)] = [ + ("내가 설정한 이름", "2026.04.01", nil, nil, true), + ("이름이름이름", "2026.04.01", nil, nil, true), + ("Example", "2026.03.27", nil, nil, true), + ("LongLongNameExampleVault","2026.04.01", nil, nil, true), + ("Google 계정", "2026.04.01", nil, "google", false), + ("GitHub 계정", "2026.04.01", nil, "github", false), + ("네이버 계정", "2026.04.01", nil, "naver", false), + ("카카오톡 계정", "2026.04.01", nil, "kakaotalk", false), + ("만료 임박 항목", "2026.04.01", .expiringSoon, "google", false), + ("이름이름이름", "2026.04.01", .expiringSoon, nil, true), + ("만료된 항목", "2026.03.27", .expired, "github", false), + ("LongLongNameExampleVault","2025.04.01", .expired, nil, true), ] var body: some View { @@ -65,6 +68,7 @@ extension DVVaultContainerPreviewView { name: vaults[index].0, date: vaults[index].1, service: vaults[index].3, + typeIcon: vaults[index].4 ? placeholderTypeIcon : nil, trailingIcon: vaults[index].2, isSelected: selectedIndex == index ) diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index 57fe1690..92395b54 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -36,7 +36,11 @@ extension SecretClient: TestDependencyKey { } case .deleted: return [Secret].preview.filter { $0.deletedAt != nil } - case .all, .liked, .project: + case .liked: + return [Secret].preview.filter { $0.deletedAt == nil && $0.liked } + case .project(let id): + return [Secret].preview(in: id) + case .all: return [Secret].preview.filter { $0.deletedAt == nil } } }, @@ -75,34 +79,51 @@ extension Secret { public extension [Secret] { /// 아바타 폴백 3단계(로고 매칭 → service 첫 글자 → secretType 아이콘)를 /// 타입 6종에 걸쳐 전부 확인하기 위한 프리뷰 픽스처. - static let preview: [Secret] = [ - // 1) 로고 매칭 - .preview, // GitHub API Key · service: "github" - secret(name: "Google 계정", type: .oauth, service: "google"), - secret(name: "네이버 계정", type: .oauth, service: "naver"), - secret(name: "카카오톡 계정", type: .oauth, service: "kakaotalk"), - - // 2) 매칭 실패, service 첫 글자로 폴백 - secret(name: "Stripe API Key", type: .apiKeyToken, service: "Stripe"), - secret(name: "CheerLot DB", type: .database, service: "CheerLot"), - - // 3) service 없음, secretType 아이콘으로 폴백 - secret(name: "이름 없는 API 키", type: .apiKeyToken, expiresAt: .now.addingTimeInterval(-86_400)), - secret(name: "사내 SSO", type: .oauth), - secret(name: "운영 DB", type: .database, expiresAt: .now.addingTimeInterval(3 * 86_400)), - secret(name: "배포 서버 SSH 키", type: .sshAndCredentials, expiresAt: .now.addingTimeInterval(20 * 86_400)), - secret(name: ".env 모음", type: .environmentVariableSet), - secret(name: "라이선스 키", type: .etc), - - // 4) Deleted 컬렉션 전용 - secret(name: "삭제된 API 키", type: .apiKeyToken, deletedAt: .now), - ] + static let preview: [Secret] = previewFixtures.map(\.secret) + + /// `.project` 컬렉션 필터링용. `Secret`엔 프로젝트 소속 필드가 없어(실제로는 별도 조인 테이블) 프리뷰에서만 매핑을 들고 있는다. + static func preview(in projectID: Project.ID) -> [Secret] { + previewFixtures + .filter { $0.secret.deletedAt == nil && $0.projectIDs.contains(projectID) } + .map(\.secret) + } + + private static let previewFixtures: [(secret: Secret, projectIDs: Set)] = { + let cheerLotID = [Project].preview[0].id + + return [ + // 1) 로고 매칭 + (.preview, [cheerLotID]), // GitHub API Key · service: "github" + (secret(name: "Google 계정", type: .oauth, service: "google"), [cheerLotID]), + (secret(name: "네이버 계정", type: .oauth, service: "naver"), []), + (secret(name: "카카오톡 계정", type: .oauth, service: "kakaotalk"), []), + + // 2) 매칭 실패, service 첫 글자로 폴백 + (secret(name: "Stripe API Key", type: .apiKeyToken, service: "Stripe"), []), + (secret(name: "CheerLot DB", type: .database, service: "CheerLot"), [cheerLotID]), + + // 3) service 없음, secretType 아이콘으로 폴백 + (secret(name: "이름 없는 API 키", type: .apiKeyToken, expiresAt: .now.addingTimeInterval(-86_400)), []), + (secret(name: "사내 SSO", type: .oauth), []), + (secret(name: "운영 DB", type: .database, expiresAt: .now.addingTimeInterval(3 * 86_400)), []), + (secret(name: "배포 서버 SSH 키", type: .sshAndCredentials, expiresAt: .now.addingTimeInterval(20 * 86_400)), []), + (secret(name: ".env 모음", type: .environmentVariableSet), []), + (secret(name: "라이선스 키", type: .etc), []), + + // 4) Deleted 컬렉션 전용 + (secret(name: "삭제된 API 키", type: .apiKeyToken, deletedAt: .now), []), + + // 5) Star(liked) 컬렉션 전용 + (secret(name: "즐겨찾는 API 키", type: .apiKeyToken, liked: true), []), + ] + }() private static func secret( name: String, type: SecretType, service: String? = nil, expiresAt: Date? = nil, + liked: Bool = false, deletedAt: Date? = nil ) -> Secret { Secret( @@ -111,6 +132,7 @@ public extension [Secret] { secretType: type, service: service, expiresAt: expiresAt, + liked: liked, deletedAt: deletedAt, createdAt: .now, updatedAt: .now, From 0acfe598636c170be21a5c211ccf45481aab9e3a Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sat, 25 Jul 2026 16:00:32 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[#54]=20update:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kakaotalk.imageset/Contents.json | 2 +- .../Sources/Components/DVVaultContainer.swift | 75 +++++++++------ .../Sources/Foundations/Color/Color+Hex.swift | 14 +++ .../ServiceLogo/ServiceLogoCatalog.swift | 56 +++++++----- .../Dependencies/SecretClient+Preview.swift | 91 +++++++++++++++++++ .../Sources/Dependencies/SecretClient.swift | 86 ------------------ 6 files changed, 185 insertions(+), 139 deletions(-) create mode 100644 Projects/DVDesign/Sources/Foundations/Color/Color+Hex.swift create mode 100644 Projects/DVPresentation/Sources/Dependencies/SecretClient+Preview.swift diff --git a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/Contents.json b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/Contents.json index 713df417..5bd102af 100644 --- a/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/Contents.json +++ b/Projects/DVDesign/Resources/Assets.xcassets/ServiceLogos/kakaotalk.imageset/Contents.json @@ -11,6 +11,6 @@ }, "properties" : { "preserves-vector-representation" : true, - "template-rendering-intent" : "template" + "template-rendering-intent" : "original" } } diff --git a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift index 3336b617..43ae7f6a 100644 --- a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift @@ -62,40 +62,61 @@ extension DVVaultContainer { @ViewBuilder private var avatarCircle: some View { if let logo = ServiceLogoCatalog.logo(forService: service) { - Circle() - .fill(logo.brandColor) - .frame(width: 44, height: 44) - .overlay( + logoAvatar(logo) + } else if let serviceInitial { + initialAvatar(serviceInitial) + } else { + typeIconAvatar + } + } + + private func logoAvatar(_ logo: DVServiceLogo) -> some View { + Circle() + .fill(logo.brandColor) + .frame(width: 44, height: 44) + .overlay { + if logo.rendersAsTemplate { Image(logo.assetName, bundle: .module) .renderingMode(.template) .resizable() .scaledToFit() - .frame(width: 22, height: 22) .foregroundStyle(Color.dv(.white)) - ) - } else if let serviceInitial { - Circle() - .fill(Color.dv(.gray300)) - .frame(width: 44, height: 44) - .overlay( - Text(serviceInitial) - .dvFont(.headingLG) + .frame(width: 22, height: 22) + } else { + // 배경+글리프가 한 path로 합쳐진 에셋: template 틴트를 걸지 않고 원본 색상 그대로 그린다. + Image(logo.assetName, bundle: .module) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + } + } + } + + private func initialAvatar(_ initial: String) -> some View { + Circle() + .fill(Color.dv(.gray300)) + .frame(width: 44, height: 44) + .overlay( + Text(initial) + .dvFont(.headingLG) + .foregroundStyle(Color.dv(.gray600)) + ) + } + + @ViewBuilder + private var typeIconAvatar: some View { + Circle() + .fill(Color.dv(.gray300)) + .frame(width: 44, height: 44) + .overlay { + if let typeIcon { + typeIcon + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) .foregroundStyle(Color.dv(.gray600)) - ) - } else { - Circle() - .fill(Color.dv(.gray300)) - .frame(width: 44, height: 44) - .overlay { - if let typeIcon { - typeIcon - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .foregroundStyle(Color.dv(.gray600)) - } } - } + } } /// 로고 매칭에 실패했지만 `service` 자체는 채워져 있을 때 쓰는 중간 폴백. diff --git a/Projects/DVDesign/Sources/Foundations/Color/Color+Hex.swift b/Projects/DVDesign/Sources/Foundations/Color/Color+Hex.swift new file mode 100644 index 00000000..73080821 --- /dev/null +++ b/Projects/DVDesign/Sources/Foundations/Color/Color+Hex.swift @@ -0,0 +1,14 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +extension Color { + /// 0xRRGGBB 형식의 브랜드 컬러 하드코딩(예: 서비스 로고 색상)에 쓴다. + init(hex: UInt32) { + self.init( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255 + ) + } +} diff --git a/Projects/DVDesign/Sources/Foundations/ServiceLogo/ServiceLogoCatalog.swift b/Projects/DVDesign/Sources/Foundations/ServiceLogo/ServiceLogoCatalog.swift index d4ada4b8..12273340 100644 --- a/Projects/DVDesign/Sources/Foundations/ServiceLogo/ServiceLogoCatalog.swift +++ b/Projects/DVDesign/Sources/Foundations/ServiceLogo/ServiceLogoCatalog.swift @@ -5,6 +5,9 @@ import SwiftUI public struct DVServiceLogo { public let assetName: String public let brandColor: Color + /// `false`면 SVG가 배경과 글리프를 한 path로 합쳐 그린다(예: KakaoTalk의 사각 배경+말풍선). + /// 이런 에셋은 `.template` 틴트를 걸면 배경까지 단색으로 뭉개지므로 원본 색상 그대로 그려야 한다. + public let rendersAsTemplate: Bool } /// `Secret.service` 자유 텍스트를 정규화해 번들된 브랜드 로고를 찾는다. @@ -13,39 +16,42 @@ public enum ServiceLogoCatalog { public static func logo(forService service: String?) -> DVServiceLogo? { guard let service else { return nil } - guard let canonical = aliases[normalize(service)] else { return nil } - return entries[canonical] + return logosByAlias[normalize(service)] } + /// 영문/숫자만 남기고 비교한다. "Google Cloud"처럼 알려지지 않은 표기는 + /// `aliases`에 미리 추가해두지 않는 한 조용히 매칭 실패해 이니셜 폴백으로 빠진다. private static func normalize(_ raw: String) -> String { raw.lowercased().filter { $0.isLetter || $0.isNumber } } - // 서비스별로 흔히 쓰이는 표기(별칭)를 대표 키로 매핑한다. - private static let aliases: [String: String] = [ - "google": "google", - "gmail": "google", - "googleworkspace": "google", - "github": "github", - "naver": "naver", - "kakao": "kakaotalk", - "kakaotalk": "kakaotalk" - ] + private struct Entry { + let logo: DVServiceLogo + let aliases: [String] + } - private static let entries: [String: DVServiceLogo] = [ - "google": DVServiceLogo(assetName: "google", brandColor: Color(hex: 0x4285F4)), - "github": DVServiceLogo(assetName: "github", brandColor: Color(hex: 0x181717)), - "naver": DVServiceLogo(assetName: "naver", brandColor: Color(hex: 0x03C75A)), - "kakaotalk": DVServiceLogo(assetName: "kakaotalk", brandColor: Color(hex: 0xFFCD00)) + private static let entries: [Entry] = [ + Entry( + logo: DVServiceLogo(assetName: "google", brandColor: Color(hex: 0x4285F4), rendersAsTemplate: true), + aliases: ["google", "gmail", "googleworkspace"] + ), + Entry( + logo: DVServiceLogo(assetName: "github", brandColor: Color(hex: 0x181717), rendersAsTemplate: true), + aliases: ["github"] + ), + Entry( + logo: DVServiceLogo(assetName: "naver", brandColor: Color(hex: 0x03C75A), rendersAsTemplate: true), + aliases: ["naver"] + ), + Entry( + logo: DVServiceLogo(assetName: "kakaotalk", brandColor: Color(hex: 0xFFCD00), rendersAsTemplate: false), + aliases: ["kakao", "kakaotalk"] + ), ] -} -private extension Color { - init(hex: UInt32) { - self.init( - red: Double((hex >> 16) & 0xFF) / 255, - green: Double((hex >> 8) & 0xFF) / 255, - blue: Double(hex & 0xFF) / 255 - ) + private static let logosByAlias: [String: DVServiceLogo] = entries.reduce(into: [:]) { result, entry in + for alias in entry.aliases { + result[alias] = entry.logo + } } } diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient+Preview.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient+Preview.swift new file mode 100644 index 00000000..b9cffaba --- /dev/null +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient+Preview.swift @@ -0,0 +1,91 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +import DVDomain + +// MARK: - Preview Fixtures + +extension Secret { + static let preview = Secret( + id: UUID(), + name: "GitHub API Key", + secretType: .apiKeyToken, + service: "github", + createdAt: .now, + updatedAt: .now, + payload: SecretPayload(encryptedData: Data(), keyTag: "preview", schemaVersion: 1) + ) +} + +extension [Secret] { + /// 아바타 폴백 3단계(로고 매칭 → service 첫 글자 → secretType 아이콘)를 + /// 타입 6종에 걸쳐 전부 확인하기 위한 프리뷰 픽스처. + public static let preview: [Secret] = previewFixtures.map(\.secret) + + /// `.project` 컬렉션 필터링용. `Secret`엔 프로젝트 소속 필드가 없어(실제로는 별도 조인 테이블) 프리뷰에서만 매핑을 들고 있는다. + public static func preview(in projectID: Project.ID) -> [Secret] { + previewFixtures + .filter { $0.secret.deletedAt == nil && $0.projectIDs.contains(projectID) } + .map(\.secret) + } + + private static let previewFixtures: [(secret: Secret, projectIDs: Set)] = { + let cheerLotID = [Project].preview[0].id + + return [ + // 1) 로고 매칭 + (.preview, [cheerLotID]), // GitHub API Key · service: "github" + (secret(name: "Google 계정", type: .oauth, service: "google"), [cheerLotID]), + (secret(name: "네이버 계정", type: .oauth, service: "naver"), []), + (secret(name: "카카오톡 계정", type: .oauth, service: "kakaotalk"), []), + + // 2) 매칭 실패, service 첫 글자로 폴백 + (secret(name: "Stripe API Key", type: .apiKeyToken, service: "Stripe"), []), + (secret(name: "CheerLot DB", type: .database, service: "CheerLot"), [cheerLotID]), + + // 3) service 없음, secretType 아이콘으로 폴백 + (secret(name: "이름 없는 API 키", type: .apiKeyToken, expiresAt: .now.addingTimeInterval(-86_400)), []), + (secret(name: "사내 SSO", type: .oauth), []), + (secret(name: "운영 DB", type: .database, expiresAt: .now.addingTimeInterval(3 * 86_400)), []), + (secret(name: "배포 서버 SSH 키", type: .sshAndCredentials, expiresAt: .now.addingTimeInterval(20 * 86_400)), []), + (secret(name: ".env 모음", type: .environmentVariableSet), []), + (secret(name: "라이선스 키", type: .etc), []), + + // 4) Deleted 컬렉션 전용 + (secret(name: "삭제된 API 키", type: .apiKeyToken, deletedAt: .now), []), + + // 5) Star(liked) 컬렉션 전용 + (secret(name: "즐겨찾는 API 키", type: .apiKeyToken, liked: true), []), + ] + }() + + private static func secret( + name: String, + type: SecretType, + service: String? = nil, + expiresAt: Date? = nil, + liked: Bool = false, + deletedAt: Date? = nil + ) -> Secret { + Secret( + id: UUID(), + name: name, + secretType: type, + service: service, + expiresAt: expiresAt, + liked: liked, + deletedAt: deletedAt, + createdAt: .now, + updatedAt: .now, + payload: SecretPayload(encryptedData: Data(), keyTag: "preview", schemaVersion: 1) + ) + } +} + +extension [Project] { + public static let preview: [Project] = [ + Project(id: UUID(), name: "CheerLot", createdAt: .now, updatedAt: .now), + Project(id: UUID(), name: "Mobile", createdAt: .now, updatedAt: .now), + ] +} diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index 92395b54..e077e9ec 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -61,89 +61,3 @@ public extension DependencyValues { set { self[SecretClient.self] = newValue } } } - -// MARK: - Preview Fixtures - -extension Secret { - static let preview = Secret( - id: UUID(), - name: "GitHub API Key", - secretType: .apiKeyToken, - service: "github", - createdAt: .now, - updatedAt: .now, - payload: SecretPayload(encryptedData: Data(), keyTag: "preview", schemaVersion: 1) - ) -} - -public extension [Secret] { - /// 아바타 폴백 3단계(로고 매칭 → service 첫 글자 → secretType 아이콘)를 - /// 타입 6종에 걸쳐 전부 확인하기 위한 프리뷰 픽스처. - static let preview: [Secret] = previewFixtures.map(\.secret) - - /// `.project` 컬렉션 필터링용. `Secret`엔 프로젝트 소속 필드가 없어(실제로는 별도 조인 테이블) 프리뷰에서만 매핑을 들고 있는다. - static func preview(in projectID: Project.ID) -> [Secret] { - previewFixtures - .filter { $0.secret.deletedAt == nil && $0.projectIDs.contains(projectID) } - .map(\.secret) - } - - private static let previewFixtures: [(secret: Secret, projectIDs: Set)] = { - let cheerLotID = [Project].preview[0].id - - return [ - // 1) 로고 매칭 - (.preview, [cheerLotID]), // GitHub API Key · service: "github" - (secret(name: "Google 계정", type: .oauth, service: "google"), [cheerLotID]), - (secret(name: "네이버 계정", type: .oauth, service: "naver"), []), - (secret(name: "카카오톡 계정", type: .oauth, service: "kakaotalk"), []), - - // 2) 매칭 실패, service 첫 글자로 폴백 - (secret(name: "Stripe API Key", type: .apiKeyToken, service: "Stripe"), []), - (secret(name: "CheerLot DB", type: .database, service: "CheerLot"), [cheerLotID]), - - // 3) service 없음, secretType 아이콘으로 폴백 - (secret(name: "이름 없는 API 키", type: .apiKeyToken, expiresAt: .now.addingTimeInterval(-86_400)), []), - (secret(name: "사내 SSO", type: .oauth), []), - (secret(name: "운영 DB", type: .database, expiresAt: .now.addingTimeInterval(3 * 86_400)), []), - (secret(name: "배포 서버 SSH 키", type: .sshAndCredentials, expiresAt: .now.addingTimeInterval(20 * 86_400)), []), - (secret(name: ".env 모음", type: .environmentVariableSet), []), - (secret(name: "라이선스 키", type: .etc), []), - - // 4) Deleted 컬렉션 전용 - (secret(name: "삭제된 API 키", type: .apiKeyToken, deletedAt: .now), []), - - // 5) Star(liked) 컬렉션 전용 - (secret(name: "즐겨찾는 API 키", type: .apiKeyToken, liked: true), []), - ] - }() - - private static func secret( - name: String, - type: SecretType, - service: String? = nil, - expiresAt: Date? = nil, - liked: Bool = false, - deletedAt: Date? = nil - ) -> Secret { - Secret( - id: UUID(), - name: name, - secretType: type, - service: service, - expiresAt: expiresAt, - liked: liked, - deletedAt: deletedAt, - createdAt: .now, - updatedAt: .now, - payload: SecretPayload(encryptedData: Data(), keyTag: "preview", schemaVersion: 1) - ) - } -} - -public extension [Project] { - static let preview: [Project] = [ - Project(id: UUID(), name: "CheerLot", createdAt: .now, updatedAt: .now), - Project(id: UUID(), name: "Mobile", createdAt: .now, updatedAt: .now), - ] -}