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..5bd102af --- /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" : "original" + } +} 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..772e4f57 100644 --- a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift +++ b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift @@ -7,15 +7,22 @@ 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), + /// `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 { @@ -60,6 +67,8 @@ extension DVVaultContainerPreviewView { DVVaultContainer( 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/DVDesign/Sources/Components/DVVaultContainer.swift b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift index 9ee02416..43ae7f6a 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,71 @@ public struct DVVaultContainer: View { extension DVVaultContainer { + @ViewBuilder private var avatarCircle: some View { + if let logo = ServiceLogoCatalog.logo(forService: service) { + 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() + .foregroundStyle(Color.dv(.white)) + .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)) + } + } + } + + /// 로고 매칭에 실패했지만 `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/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 new file mode 100644 index 00000000..12273340 --- /dev/null +++ b/Projects/DVDesign/Sources/Foundations/ServiceLogo/ServiceLogoCatalog.swift @@ -0,0 +1,57 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +public struct DVServiceLogo { + public let assetName: String + public let brandColor: Color + /// `false`면 SVG가 배경과 글리프를 한 path로 합쳐 그린다(예: KakaoTalk의 사각 배경+말풍선). + /// 이런 에셋은 `.template` 틴트를 걸면 배경까지 단색으로 뭉개지므로 원본 색상 그대로 그려야 한다. + public let rendersAsTemplate: Bool +} + +/// `Secret.service` 자유 텍스트를 정규화해 번들된 브랜드 로고를 찾는다. +/// 매칭되는 항목이 없으면 `nil`을 반환하며, 호출부는 이니셜 플레이스홀더로 폴백해야 한다. +public enum ServiceLogoCatalog { + + public static func logo(forService service: String?) -> DVServiceLogo? { + guard let service else { return nil } + return logosByAlias[normalize(service)] + } + + /// 영문/숫자만 남기고 비교한다. "Google Cloud"처럼 알려지지 않은 표기는 + /// `aliases`에 미리 추가해두지 않는 한 조용히 매칭 실패해 이니셜 폴백으로 빠진다. + private static func normalize(_ raw: String) -> String { + raw.lowercased().filter { $0.isLetter || $0.isNumber } + } + + private struct Entry { + let logo: DVServiceLogo + let aliases: [String] + } + + 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 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 1c61f104..e077e9ec 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -28,7 +28,22 @@ 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 .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 } + } + }, softDelete: { _ in .preview }, restore: { _ in .preview }, permanentlyDelete: { _ in }, @@ -46,23 +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, - 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), - ] -} 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" + } + } +}