diff --git a/.github/workflows/gemini-code-review.yml b/.github/workflows/gemini-code-review.yml new file mode 100644 index 0000000..e8fe47f --- /dev/null +++ b/.github/workflows/gemini-code-review.yml @@ -0,0 +1,270 @@ +name: Gemini Code Review + +on: + pull_request: + branches: + - develop + - main + - master + types: [opened, synchronize] + +concurrency: + group: gemini-code-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + code-review: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install GoogleGenerativeAI + run: npm install @google/generative-ai + + - name: Get PR Context and Filtered Git Diff + id: get_diff + run: | + git fetch origin "${{ github.event.pull_request.base.ref }}" + git fetch origin "${{ github.event.pull_request.head.ref }}" + git diff "origin/${{ github.event.pull_request.base.ref }}"..."origin/${{ github.event.pull_request.head.ref }}" -- "*.swift" > diff.txt + if [ ! -s diff.txt ]; then + echo "skip_review=true" >> $GITHUB_OUTPUT + else + echo "skip_review=false" >> $GITHUB_OUTPUT + fi + + - name: Parse Diff for Valid Lines and Annotate + if: steps.get_diff.outputs.skip_review == 'false' + uses: actions/github-script@v7 + id: parse_diff + with: + script: | + const fs = require("fs"); + const diff = fs.readFileSync("diff.txt", "utf8"); + const validLines = {}; + const lineContentMap = {}; + const annotatedLines = []; + let currentFile = null; + let lineNum = 0; + for (const line of diff.split("\n")) { + if (line.startsWith("diff --git")) { + const match = line.match(/b\/(.+)$/); + if (match) currentFile = match[1]; + annotatedLines.push(line); + continue; + } + if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("index ")) { + annotatedLines.push(line); + continue; + } + if (line.startsWith("@@") && currentFile) { + const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (match) lineNum = parseInt(match[1]); + annotatedLines.push(line); + continue; + } + if (!currentFile) { + annotatedLines.push(line); + continue; + } + if (line.startsWith("+") && !line.startsWith("+++")) { + if (!validLines[currentFile]) validLines[currentFile] = new Set(); + validLines[currentFile].add(lineNum); + if (!lineContentMap[currentFile]) lineContentMap[currentFile] = {}; + lineContentMap[currentFile][lineNum] = line.substring(1).trim(); + annotatedLines.push(`[LINE ${lineNum}] ${line}`); + lineNum++; + } else if (line.startsWith("-") && !line.startsWith("---")) { + annotatedLines.push(`[DEL] ${line}`); + } else { + annotatedLines.push(`[CTX ${lineNum}] ${line}`); + lineNum++; + } + } + const serializable = {}; + for (const [file, lines] of Object.entries(validLines)) { + serializable[file] = [...lines].sort((a, b) => a - b); + } + fs.writeFileSync("valid_lines.json", JSON.stringify(serializable)); + fs.writeFileSync("line_content_map.json", JSON.stringify(lineContentMap)); + fs.writeFileSync("annotated_diff.txt", annotatedLines.join("\n")); + + - name: Run Gemini Review + if: steps.get_diff.outputs.skip_review == 'false' + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const annotated_diff = fs.readFileSync("annotated_diff.txt", "utf8"); + const pr_title = context.payload.pull_request.title; + const pr_body = context.payload.pull_request.body || "내용 없음"; + const { GoogleGenerativeAI } = require("@google/generative-ai"); + const genAI = new GoogleGenerativeAI("${{ secrets.GEMINI_API_KEY }}"); + const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + generationConfig: { + responseMimeType: "application/json", + }, + }); + const prompt = `You are a senior iOS engineer performing a code review on a Swift 6 / SwiftUI / TCA 1.25 multi-module Clean Architecture project built with Tuist 4. + + [Tone and Style Guidelines] + - Do NOT include unnecessary praise, greetings, or overly verbose explanations. + - Do NOT provide unsolicited CS insights (컴퓨터 과학적 통찰) or related interview questions. + - Provide concise, objective, and well-organized feedback suitable for immediate practical use. + + CRITICAL LINE NUMBER RULES (MUST FOLLOW): + - Each diff line is annotated with [LINE N] for added lines or [CTX N] for context lines. + - You MUST only comment on [LINE N] lines (added/modified code). NEVER comment on [CTX] or [DEL] lines. + - Use the EXACT number N shown in the [LINE N] annotation. Do NOT compute line numbers yourself. + - The "code_snippet" field must contain the actual code from that [LINE N] line. + + [PR Context] + Title: ${pr_title} + Description: ${pr_body} + + [Project Architecture] + - Stack: Swift 6, SwiftUI, TCA 1.25, Tuist 4 (multi-module) + - Layer dependency: Presentation → Domain ← Data, Network is only referenced by Data + - Deployment target: iOS 26.0 (iPhone only) + - Source roots: Projects/App, Projects/Presentation, Projects/Domain/{Entity,UseCase,DomainInterface,DataInterface}, Projects/Data/{Model,Repository,API,Service}, Projects/Network/*, Projects/Shared/* + + [Review Criteria] + 1. TCA Convention: Verify @Reducer + @ObservableState usage; Action naming describes events that occurred (e.g., xxxButtonTapped, xxxResponse), NOT intended effects (e.g., performLogin, loadData); Effect is .none when no side effect and .run for async work; shared logic lives in private methods, NOT shared Actions; Effect.run must NOT capture entire @ObservableState (extract needed values first); Reducer must NOT perform CPU-intensive work (offload to Effect); Store.scope must use stored property paths only (no computed transforms); Navigation uses @Reducer enum; transient UI state (hover, focus, animation) stays in SwiftUI @State, not TCA State. + 2. Module Architecture: Respect Presentation → Domain ← Data dependency direction; Network is only imported by Data; module boundaries expose protocols (DomainInterface / DataInterface); DTO-to-Entity mapping stays in Data layer. + 3. SwiftUI Convention: SubViews are structs (NOT @ViewBuilder functions); use @Binding when a SubView mutates parent @State; no "View" suffix in View names (unless clarity requires it); use .frame(maxWidth/maxHeight: .infinity) instead of Spacer() for simple expansion; required props via init, optional props via ViewModifier-style functions. + 4. Swift Code Quality: guard early return with shorthand optional binding (guard let value else { ... }) followed by a blank line; final class by default; private first (avoid fileprivate unless required); never force unwrap; operator line break puts operator at the start of the next line; function params line-break with closing paren on its own line; ternary for simple return/assignment only, split on '?'; [weak self] + guard let self else { return } in closures; constant groups as private enum (Metric/Font/Constant), NOT struct; empty collection literals ([] / [:]); indent 4 spaces; 120-char line limit. + 5. Actionable Feedback: When improvement is needed you MUST provide a concrete Swift fix using GitHub's \`\`\`suggestion block. + + [Severity Prefix] + Each comment body MUST start with a severity tag on its own line, then a blank line, then the actual comment: + - 🔴 [P1] Critical: force-unwrap crash risk, retain cycles / memory leaks, heavy or blocking work inside a Reducer, main-thread blocking + - 🟠 [P2] Major: module dependency-direction violations (e.g., Domain importing Data), Effect.run capturing entire @ObservableState, sharing logic through Actions, Store.scope with computed property, serious concurrency or error-mapping issues + - 🟡 [P3] Minor: Action naming that describes intent/effect (performLogin, loadData, setRecords), SubView written as @ViewBuilder function, Swift API Design Guideline violations on public APIs, inefficient Effect composition + - 🔵 [P4] Readability: View-suffix naming, Spacer() misuse, missing final / private, guard / ternary / line-break style violations, constant groups declared as struct instead of enum + - ⚪ [P5] Nitpick: typos, whitespace, formatting + + Format: "🔴 **[P1] Critical**\\n\\nActual comment content here..." + + Ignore comments and formatting-only changes. Write all review comments in Korean using Markdown, without greetings or closings. + + Respond ONLY with a JSON object in this exact format: + { + "summary": "전체 리뷰 요약 (한국어, 마크다운)", + "comments": [ + { + "path": "file path relative to repo root (from the b/ prefix in diff)", + "line": , + "code_snippet": "the actual code content from that line", + "body": "🔴/🟠/🟡/🔵/⚪ **[P1~P5] Label**\\n\\n리뷰 코멘트 (한국어, 마크다운. 개선이 필요하면 \\\`\\\`\\\`suggestion 블록 포함)" + } + ] + } + If no issues are found, return {"summary": "...", "comments": []}. + + + ${annotated_diff} + `; + const result = await model.generateContent(prompt); + const text = result.response.text(); + fs.writeFileSync("review_result.json", text); + + - name: Post Inline Review Comments + if: steps.get_diff.outputs.skip_review == 'false' + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const raw = fs.readFileSync("review_result.json", "utf8"); + const validLinesMap = JSON.parse(fs.readFileSync("valid_lines.json", "utf8")); + const lineContentMap = JSON.parse(fs.readFileSync("line_content_map.json", "utf8")); + let review; + try { + review = JSON.parse(raw); + } catch (e) { + console.log("JSON parse failed, falling back to single comment"); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: raw, + }); + return; + } + + function snapToValidLine(comment) { + const fileLines = validLinesMap[comment.path]; + if (!fileLines || fileLines.length === 0) { + console.log(`Dropping comment: file not in diff - ${comment.path}`); + return null; + } + if (fileLines.includes(comment.line)) return comment.line; + + const fileContent = lineContentMap[comment.path] || {}; + if (comment.code_snippet) { + const snippet = comment.code_snippet.trim(); + for (const validLine of fileLines) { + const content = fileContent[validLine] || ""; + if (content.includes(snippet) || snippet.includes(content)) { + return validLine; + } + } + } + + const THRESHOLD = 5; + let bestLine = null; + let bestDist = THRESHOLD + 1; + for (const validLine of fileLines) { + const dist = Math.abs(validLine - comment.line); + if (dist < bestDist) { + bestDist = dist; + bestLine = validLine; + } + } + if (bestLine !== null) return bestLine; + return null; + } + + const reviewComments = (review.comments || []) + .map((c) => { + const snappedLine = snapToValidLine(c); + if (snappedLine === null) return null; + return { + path: c.path, + line: snappedLine, + side: "RIGHT", + body: c.body, + }; + }) + .filter(Boolean); + + if (reviewComments.length > 0) { + await github.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + event: "COMMENT", + body: review.summary || "", + comments: reviewComments, + }); + } else if (review.summary) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: review.summary, + }); + } diff --git a/AGENTS.md b/AGENTS.md index c6522af..2f62e84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,55 @@ public var body: some View { - 한 메서드 안에서 다시 큰 블록이 생기면 더 작게 쪼개기 (재귀 적용) - 공통 컴포넌트는 별도 파일 (`Components/*.swift`) 로 추출 +#### 🧱 `@ViewBuilder` 함수 vs `var` — 자식 개수 / 분기 유무로 결정 + +분리한 sub-view 의 선언 형태는 **자식 개수와 분기 유무** 로만 정한다. + +```swift +// ✅ 다중 자식을 감싸거나 if/else · switch 분기가 있으면 `@ViewBuilder` + 함수 +@ViewBuilder +private func hotBattlesSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + HomeSectionHeader(title: "지금 뜨는 배틀") { send(.seeMoreTapped(.hotBattles)) } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(store.hotBattles) { HotBattleCardView(battle: $0) } + } + } + } +} + +@ViewBuilder +private func thumbnail(url: URL?) -> some View { + if let url { + KFImage(url).resizable().scaledToFill() + } else { + Color.neutral200 + } +} + +// ✅ 단일 뷰만 반환하면 `private var` 형태 +private var primaryButton: some View { + CustomButton( + action: { send(.primaryButtonTapped) }, + title: "사전 투표하기", + config: CustomButtonConfig.primary(.large, height: 52), + isEnable: store.isPrimaryButtonEnabled + ) +} + +// ❌ 금지 — VStack 으로 자식 여러 개 감싸는데 var 만 쓰는 경우 (분기 / 동적 children 추가 시 깨짐) +private var section: some View { + VStack { ... } // → @ViewBuilder + func 으로 가야 안전 +} +``` + +규칙: +- **`@ViewBuilder` + `private func`** : VStack/HStack/ZStack 등으로 **자식 ≥ 2개** 를 감싸거나 `if` / `switch` / `ForEach` 같은 분기·반복이 있을 때 +- **`private var ...: some View`** : **단일 뷰** 1개만 반환할 때 (단순 wrapping · CTA 버튼 · 단일 Image 등) +- body 안에서 호출하는 sub-view 가 인자가 필요하면 함수, 없으면 var 가 우선 — 기준은 "자식 수 / 분기 유무" 가 먼저 +- 레퍼런스: `HomeView.hotBattlesSection`, `PreVoteView.primaryButton`, `HeroCardView.thumbnail` + #### 🔤 폰트 — `.font(.system(...))` 금지, Pretendard 토큰 사용 ```swift @@ -414,6 +463,41 @@ public init( - `MoyaProviderPool` 은 더 이상 RepositoryImpl 에서 직접 호출하지 않는다 (필요 시 풀 자체에서 내부적으로 캐시 처리) - 레퍼런스: `HomeRepositoryImpl`, `AuthRepositoryImpl`, AsyncMoya `MoyaProvider+Factory.default`, `Extension+MoyaProvider+Auth.authorized` +#### 🧭 Coordinator — `extension X { @Reducer public enum XScreen { ... } }` 구조 절대 건드리지 말 것 + +TCAFlow 기반 Coordinator 의 `XScreen` 정의는 반드시 **별도 extension 의 `@Reducer public enum`** 형태를 유지한다. 마이그레이션 / 리팩터링 / 자동 포맷터 어떤 이유로도 이 구조를 본체 안으로 끌어들이거나 `enum`을 `struct`/`Reducer` 로 바꾸지 말 것. + +```swift +// ✅ 올바른 패턴 — extension + @Reducer + public enum + State: Equatable 보조 extension +@FlowCoordinator(screen: "HomeScreen", navigation: true) +public struct HomeCoordinator { + // … State / Action / handleRoute … +} + +extension HomeCoordinator { + @Reducer + public enum HomeScreen { + case home(HomeFeature) + case preVote(PreVoteFeature) + } +} + +extension HomeCoordinator.HomeScreen.State: Equatable {} + +// ❌ 금지 — Coordinator 본체 안에 enum 을 인라인 선언 +public struct HomeCoordinator { + @Reducer + public enum HomeScreen { ... } // 안 됨 (매크로 인식 / Route 추론 깨짐) +} +``` + +규칙: +- `XScreen` 은 `@Reducer public enum`. struct 로 바꾸지 말 것 +- 본체와 분리된 **별도 extension** 안에 선언 +- `extension Coordinator.XScreen.State: Equatable {}` 보조 conformance 도 같이 유지 (Route diff 비교 필요) +- 라우터 핸들러 (`routerAction`) 안에서 `state.routes.push/pop/goBack` 직접 호출은 OK, 단 `dismiss`/`submit` 같이 반복되는 종료 액션은 `.send(.view(.backAction))` 으로 일원화 +- 레퍼런스: `HomeCoordinator`, `AuthCoordinator`, `MainTabCoordinator` + ### 📏 Swift 코딩 규칙 (`docs/agent/swift-coding-rules.md`) - Swift 스타일 가이드 - 에러 처리 패턴 diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 0668f4e..a37d865 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -1,7 +1,7 @@ -import ProjectDescription +import DependencyPackagePlugin import DependencyPlugin +import ProjectDescription import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeAppModule( name: Project.Environment.appName, @@ -16,8 +16,9 @@ let project = Project.makeAppModule( .SPM.googleMobileAds, .SPM.firebaseCrashlytics, .SPM.mixpanel, - .SPM.mixpanelSessionReplay - + .SPM.mixpanelSessionReplay, + .SPM.kingfisher, + ], sources: ["Sources/**"], resources: ["Resources/**"], diff --git a/Projects/App/Sources/Application/AppDelegate.swift b/Projects/App/Sources/Application/AppDelegate.swift index f8b2252..d1f69e4 100644 --- a/Projects/App/Sources/Application/AppDelegate.swift +++ b/Projects/App/Sources/Application/AppDelegate.swift @@ -1,24 +1,25 @@ -import UIKit -import WeaveDI import Firebase import GoogleMobileAds import LogMacro import Mixpanel import MixpanelSessionReplay +import UIKit +import WeaveDI +import DomainInterface class AppDelegate: UIResponder, UIApplicationDelegate { let mixPanelKey = Bundle.main.object(forInfoDictionaryKey: "MIXPANEL_TOKEN") as? String func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + _: UIApplication, + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { FirebaseApp.configure() #logDebug( "Mixpanel initialize", [ "token_exists": !(mixPanelKey?.isEmpty ?? true), - "token_prefix": String((mixPanelKey ?? "").prefix(6)) + "token_prefix": String((mixPanelKey ?? "").prefix(6)), ] ) Mixpanel.initialize(token: mixPanelKey ?? "", trackAutomaticEvents: true) @@ -40,32 +41,41 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // DI 관리자 초기화 WeaveDI.Container.bootstrapInTask { @DIContainerActor _ in await AppDIManager.shared.registerDefaultDependencies() + + // Kingfisher 글로벌 requestModifier 등록 — DI 등록 직후라 KeychainManaging resolve 보장 + if let keychainManager = UnifiedDI.resolve(KeychainManaging.self) { + await MainActor.run { + KingfisherConfigurator.configureAuthorizedDownloader( + keychainManager: keychainManager + ) + } + } } return true } - + func application( - _ application: UIApplication, + _: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions + options _: UIScene.ConnectionOptions ) -> UISceneConfiguration { - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } - + func application( - _ application: UIApplication, - didDiscardSceneSessions sceneSessions: Set - ) { - } - + _: UIApplication, + didDiscardSceneSessions _: Set + ) {} + // MARK: - Image Caching Configuration + private func initializeMixpanelSessionReplay() { guard !(mixPanelKey?.isEmpty ?? true) else { return } - + var config = MPSessionReplayConfig(wifiOnly: false) config.enableSessionReplayOniOS26AndLater = true - + MPSessionReplay.initialize( token: Mixpanel.mainInstance().apiToken, distinctId: Mixpanel.mainInstance().distinctId, diff --git a/Projects/App/Sources/Application/KingfisherConfigurator.swift b/Projects/App/Sources/Application/KingfisherConfigurator.swift new file mode 100644 index 0000000..7800a6e --- /dev/null +++ b/Projects/App/Sources/Application/KingfisherConfigurator.swift @@ -0,0 +1,48 @@ +// +// KingfisherConfigurator.swift +// App +// +// Picke 백엔드 보호 이미지(`/api/v1/resources/...`) 를 KFImage 로 로딩하려면 +// 요청마다 Bearer 토큰이 필요하다. 앱 시작 시 한 번만 호출해서 +// KingfisherManager 의 defaultOptions 에 글로벌 requestModifier 를 등록한다. +// + +import Foundation + +import Kingfisher + +import DomainInterface + +enum KingfisherConfigurator { + /// 보호 이미지 (picke 백엔드 `/api/v1/resources/...`) 에만 Bearer 토큰을 첨부한다. + /// 그 외 외부 호스트 (picsum.photos / 카카오 CDN 등) 로는 토큰을 절대 보내지 않는다. + private static let protectedHostSuffixes: Set = [ + "picke.store", + "dev.picke.store", + ] + + static func configureAuthorizedDownloader( + keychainManager: KeychainManaging + ) { + let modifier = AnyModifier { request in + var req = request + + guard + let url = req.url, + let host = url.host?.lowercased(), + protectedHostSuffixes.contains(where: { host == $0 || host.hasSuffix(".\($0)") }), + url.path.hasPrefix("/api/"), + let token = keychainManager.accessToken(), !token.isEmpty + else { + return req + } + + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return req + } + + KingfisherManager.shared.defaultOptions = [ + .requestModifier(modifier), + ] + } +} diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index d47613f..0576dda 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -36,6 +36,7 @@ public final class AppDIManager: Sendable { // 🏗️ Repository 계층 (Clean Architecture + PFW) .register { AuthRepositoryImpl() as AuthInterface } .register { HomeRepositoryImpl() as HomeInterface } + .register { PollRepositoryImpl() as PollInterface } // .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift index 74a9302..5d26192 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -13,6 +13,7 @@ public enum PieckeDomain { case auth case profile case home + case poll } extension PieckeDomain: DomainType { @@ -28,6 +29,8 @@ extension PieckeDomain: DomainType { return"api/v1/me/" case .home: return"api/v1/home" + case .poll: + return"api/v1/poll" } } } diff --git a/Projects/Data/API/Sources/Poll/PollAPI.swift b/Projects/Data/API/Sources/Poll/PollAPI.swift new file mode 100644 index 0000000..1ae62d7 --- /dev/null +++ b/Projects/Data/API/Sources/Poll/PollAPI.swift @@ -0,0 +1,19 @@ +// +// PollAPI.swift +// API +// +// Created by Wonji Suh on 5/19/26. +// + +import Foundation + +public enum PollAPI { + case detailPoll(pollId: Int) + + public var description: String { + switch self { + case .detailPoll(let pollId): + return "/\(pollId)" + } + } +} diff --git a/Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift b/Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift new file mode 100644 index 0000000..cb55823 --- /dev/null +++ b/Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift @@ -0,0 +1,29 @@ +// +// PollDataDTO.swift +// Model +// +// `GET /api/v1/polls/{pollId}` 의 data 페이로드. +// + +import Foundation + +public struct PollDataDTO: Decodable { + public let pollId: Int + public let titlePrefix: String + public let titleSuffix: String + public let targetDate: String? + public let status: String + public let options: [PollOptionDTO] +} + +public struct PollOptionDTO: Decodable, Identifiable { + public let optionId: Int + public let label: String + public let title: String + public let displayOrder: Int + public let voteCount: Int + + public var id: Int { optionId } +} + +public typealias PollResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Poll/Mapper/PollDataDTO+.swift b/Projects/Data/Model/Sources/Poll/Mapper/PollDataDTO+.swift new file mode 100644 index 0000000..a4c1e0f --- /dev/null +++ b/Projects/Data/Model/Sources/Poll/Mapper/PollDataDTO+.swift @@ -0,0 +1,43 @@ +// +// PollDataDTO+.swift +// Model +// + +import Entity +import Foundation + +private let pollDateFormatter: DateFormatter = { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "yyyy-MM-dd" + return f +}() + +public extension PollOptionDTO { + func toDomain() -> PollOption { + PollOption( + optionId: optionId, + label: label, + title: title, + displayOrder: displayOrder, + voteCount: voteCount + ) + } +} + +public extension PollDataDTO { + func toDomain() -> PollDetail { + PollDetail( + pollId: pollId, + titlePrefix: titlePrefix, + titleSuffix: titleSuffix, + targetDate: targetDate.flatMap { pollDateFormatter.date(from: $0) }, + status: PollStatus(rawValue: status), + options: options + .sorted { $0.displayOrder < $1.displayOrder } + .map { $0.toDomain() } + ) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift index 53b1fde..3603724 100644 --- a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -38,14 +38,16 @@ public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { public func login( provider socialProvider: SocialType, authorizationCode: String, - redirectUri: String + redirectUri: String?, + idToken: String? ) async throws -> LoginEntity { let dto: LoginResponseDTO = try await provider.request( .login( provider: socialProvider, body: OAuthLoginRequest( authorizationCode: authorizationCode, - redirectUri: redirectUri + redirectUri: redirectUri, + idToken: idToken ) ) ) diff --git a/Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift b/Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift new file mode 100644 index 0000000..d05597a --- /dev/null +++ b/Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift @@ -0,0 +1,38 @@ +// +// PollRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class PollRepositoryImpl: PollInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchPoll(pollId: Int) async throws -> PollDetail { + let dto: PollResponseDTO = try await provider.request(.detailPoll(pollId: pollId)) + + guard let data = dto.data else { + let message = dto.error?.message ?? "투표 데이터 응답이 비어 있습니다" + Log.error("[PollRepositoryImpl] empty poll payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } +} diff --git a/Projects/Data/Service/Project.swift b/Projects/Data/Service/Project.swift index 2ed4c4b..6d7abb2 100644 --- a/Projects/Data/Service/Project.swift +++ b/Projects/Data/Service/Project.swift @@ -11,6 +11,7 @@ let project = Project.makeModule( settings: .settings(), dependencies: [ .Data(implements: .API), + .Domain(implements: .Entity), .Network(implements: .Foundations), .SPM.asyncMoya, ], diff --git a/Projects/Data/Service/Sources/Auth/OAuthRequest.swift b/Projects/Data/Service/Sources/Auth/OAuthRequest.swift index 21d7fd1..7176e7c 100644 --- a/Projects/Data/Service/Sources/Auth/OAuthRequest.swift +++ b/Projects/Data/Service/Sources/Auth/OAuthRequest.swift @@ -7,16 +7,27 @@ import Foundation -/// `/api/v1/auth/login/{provider}` 요청 바디 +/// `/api/v1/auth/login/{provider}` 요청 바디. +/// - `idToken`: Apple 로그인에서만 채워서 보낸다 (JSON key: `identityToken`). +/// - `redirectUri`: Apple 은 nil 로 보낸다 (서버에서 redirect 사용 X). public struct OAuthLoginRequest: Encodable { public let authorizationCode: String - public let redirectUri: String + public let redirectUri: String? + public let idToken: String? + + enum CodingKeys: String, CodingKey { + case authorizationCode + case redirectUri + case idToken = "identityToken" + } public init( authorizationCode: String, - redirectUri: String + redirectUri: String?, + idToken: String? = nil ) { self.authorizationCode = authorizationCode self.redirectUri = redirectUri + self.idToken = idToken } } diff --git a/Projects/Data/Service/Sources/Poll/PollService.swift b/Projects/Data/Service/Sources/Poll/PollService.swift new file mode 100644 index 0000000..d77d8f0 --- /dev/null +++ b/Projects/Data/Service/Sources/Poll/PollService.swift @@ -0,0 +1,45 @@ +// +// PollService.swift +// Service +// +// Created by Wonji Suh on 5/19/26. +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum PollService { + case detailPoll(pollId: Int) +} + +extension PollService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .poll } + + public var urlPath: String { + switch self { + case let .detailPoll(pollId): + return PollAPI.detailPoll(pollId: pollId).description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .detailPoll: + .get + } + } + + public var parameters: [String: Any]? { nil } + + public var headers: [String: String]? { + APIHeader.baseHeader + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift index 11d7bca..80897ec 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift @@ -14,7 +14,8 @@ public protocol AuthInterface: Sendable { func login( provider: SocialType, authorizationCode: String, - redirectUri: String + redirectUri: String?, + idToken: String? ) async throws -> LoginEntity func refresh() async throws -> AuthTokens func withDraw(token: String) async throws -> WithdrawEntity diff --git a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift index a80261c..0919124 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift @@ -15,7 +15,8 @@ public final class DefaultAuthRepositoryImpl: AuthInterface, @unchecked Sendable public func login( provider: SocialType, authorizationCode _: String, - redirectUri _: String + redirectUri _: String?, + idToken _: String? ) async throws -> LoginEntity { LoginEntity( name: "Mock User", diff --git a/Projects/Domain/DomainInterface/Sources/Auth/MockAuthRepository.swift b/Projects/Domain/DomainInterface/Sources/Auth/MockAuthRepository.swift index 6e8d2d6..8caa5e4 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/MockAuthRepository.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/MockAuthRepository.swift @@ -45,7 +45,8 @@ public final class MockAuthRepository: AuthInterface, @unchecked Sendable { public func login( provider: SocialType, authorizationCode _: String, - redirectUri _: String + redirectUri _: String?, + idToken _: String? ) async throws -> LoginEntity { loginCallCount += 1 try await Task.sleep(for: .milliseconds(10)) diff --git a/Projects/Domain/DomainInterface/Sources/Manager/AuthLocalStorage.swift b/Projects/Domain/DomainInterface/Sources/Manager/AuthLocalStorage.swift new file mode 100644 index 0000000..a485de7 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Manager/AuthLocalStorage.swift @@ -0,0 +1,29 @@ +// +// AuthLocalStorage.swift +// DomainInterface +// +// Apple OAuth 가 응답으로 내려준 authorizationCode 와 identityToken 을 +// 로컬에 보관해두는 UserDefaults wrapper. Keychain 과 달리 단순 캐시 용도. +// + +import Foundation + +public enum AuthLocalStorage { + private static let authCodeKey = "picke.auth.authCode" + private static let idTokenKey = "picke.auth.idToken" + + public static var authCode: String? { + get { UserDefaults.standard.string(forKey: authCodeKey) } + set { UserDefaults.standard.set(newValue, forKey: authCodeKey) } + } + + public static var idToken: String? { + get { UserDefaults.standard.string(forKey: idTokenKey) } + set { UserDefaults.standard.set(newValue, forKey: idTokenKey) } + } + + public static func clear() { + UserDefaults.standard.removeObject(forKey: authCodeKey) + UserDefaults.standard.removeObject(forKey: idTokenKey) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift new file mode 100644 index 0000000..b0034fd --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift @@ -0,0 +1,15 @@ +// +// DefaultPollRepositoryImpl.swift +// DomainInterface +// + +import Entity +import Foundation + +public struct DefaultPollRepositoryImpl: PollInterface { + public init() {} + + public func fetchPoll(pollId _: Int) async throws -> PollDetail { + .mock + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift b/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift new file mode 100644 index 0000000..81df867 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift @@ -0,0 +1,31 @@ +// +// PollInterface.swift +// DomainInterface +// + +import Entity +import Foundation +import WeaveDI + +public protocol PollInterface: Sendable { + func fetchPoll(pollId: Int) async throws -> PollDetail +} + +public struct PollRepositoryDependency: DependencyKey { + public static var liveValue: PollInterface { + UnifiedDI.resolve(PollInterface.self) ?? DefaultPollRepositoryImpl() + } + + public static var testValue: PollInterface { + UnifiedDI.resolve(PollInterface.self) ?? DefaultPollRepositoryImpl() + } + + public static var previewValue: PollInterface = liveValue +} + +public extension DependencyValues { + var pollRepository: PollInterface { + get { self[PollRepositoryDependency.self] } + set { self[PollRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/HotBattle.swift b/Projects/Domain/Entity/Sources/Home/HotBattle.swift index d75afa5..42c5077 100644 --- a/Projects/Domain/Entity/Sources/Home/HotBattle.swift +++ b/Projects/Domain/Entity/Sources/Home/HotBattle.swift @@ -41,6 +41,7 @@ public extension HotBattle { static let mocks: [HotBattle] = [ .init( battleId: 11, + thumbnailURL: URL(string: "https://picsum.photos/seed/picke-hot-1/400/250"), title: "인간은 본래 선한가, 악한가?", tags: [.init(tagId: 301, name: "#철학", type: .category)], audioDuration: 8 * 60, @@ -48,6 +49,7 @@ public extension HotBattle { ), .init( battleId: 12, + thumbnailURL: URL(string: "https://picsum.photos/seed/picke-hot-2/400/250"), title: "안락사 도입, 당신의 입장은?", tags: [.init(tagId: 302, name: "#역사", type: .category)], audioDuration: 5 * 60, @@ -55,6 +57,7 @@ public extension HotBattle { ), .init( battleId: 13, + thumbnailURL: URL(string: "https://picsum.photos/seed/picke-hot-3/400/250"), title: "노키즈존, 영업의 자유인가?", tags: [.init(tagId: 303, name: "#사회", type: .category)], audioDuration: 5 * 60, @@ -62,6 +65,7 @@ public extension HotBattle { ), .init( battleId: 14, + thumbnailURL: URL(string: "https://picsum.photos/seed/picke-hot-4/400/250"), title: "AI는 의식을 가질 수 있는가?", tags: [.init(tagId: 304, name: "#과학", type: .category)], audioDuration: 6 * 60, diff --git a/Projects/Domain/Entity/Sources/Home/NewBattle.swift b/Projects/Domain/Entity/Sources/Home/NewBattle.swift index b80cdac..0830e3d 100644 --- a/Projects/Domain/Entity/Sources/Home/NewBattle.swift +++ b/Projects/Domain/Entity/Sources/Home/NewBattle.swift @@ -62,28 +62,43 @@ public extension NewBattle { static let mocks: [NewBattle] = [ .init( battleId: 51, + thumbnailURL: URL(string: "https://picsum.photos/seed/picke-new-1/400/200"), title: "인간은 본래 선한가, 악한가?", summary: "인간 본성의 선악과 문명의 역할에 관한 철학적 대결!", - philosopherA: "순자", optionATitle: "악하다", - philosopherB: "순자", optionBTitle: "악하다", + philosopherA: "순자", + optionATitle: "악하다", + philosopherAImageURL: URL(string: "https://picsum.photos/seed/picke-philo-a/80/80"), + philosopherB: "순자", + optionBTitle: "악하다", + philosopherBImageURL: URL(string: "https://picsum.photos/seed/picke-philo-b/80/80"), tags: [.init(tagId: 501, name: "#철학", type: .category)], audioDuration: 5 * 60, viewCount: 726 ), .init( battleId: 52, + thumbnailURL: URL(string: "https://picsum.photos/seed/picke-new-2/400/200"), title: "노키즈존: 영업상의 자유인가, 공공장소에서의 차별인가?", summary: "옆 테이블 아이의 울음소리가 평화로운 휴식시간을 깨뜨린다면?", - philosopherA: "순자", optionATitle: "악하다", - philosopherB: "순자", optionBTitle: "악하다", + philosopherA: "순자", + optionATitle: "악하다", + philosopherAImageURL: URL(string: "https://picsum.photos/seed/picke-philo-a/80/80"), + philosopherB: "순자", + optionBTitle: "악하다", + philosopherBImageURL: URL(string: "https://picsum.photos/seed/picke-philo-b/80/80"), tags: [.init(tagId: 502, name: "#사회", type: .category)], audioDuration: 5 * 60, viewCount: 726 ), .init( battleId: 53, + thumbnailURL: URL(string: "https://picsum.photos/seed/picke-new-3/400/200"), title: "사후세계는 존재하는가, 인간이 만든 위안인가?", summary: "죽음은 끝일까요, 아니면 다른 방식의 시작일까요?", - philosopherA: "순자", optionATitle: "악하다", - philosopherB: "순자", optionBTitle: "악하다", + philosopherA: "순자", + optionATitle: "악하다", + philosopherAImageURL: URL(string: "https://picsum.photos/seed/picke-philo-a/80/80"), + philosopherB: "순자", + optionBTitle: "악하다", + philosopherBImageURL: URL(string: "https://picsum.photos/seed/picke-philo-b/80/80"), tags: [.init(tagId: 503, name: "#철학", type: .category)], audioDuration: 5 * 60, viewCount: 726 ), diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift b/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift new file mode 100644 index 0000000..4fa840f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift @@ -0,0 +1,79 @@ +// +// PreVoteBattle.swift +// Entity +// +// Created by Wonji Suh on 5/17/26. +// + +import Foundation + +/// 사전 투표창 (.pen `U5WO4`) 에 표시되는 배틀 모델. +/// 홈 카드의 `VoteQuestion` 과 달리 2지선다 + 철학자 아바타 기반. +public struct PreVoteBattle: Equatable, Identifiable { + public let battleId: Int + public let backgroundImageURL: String? + public let tags: [String] + public let titleLine1: String + public let titleLine2: String + public let summary: String + public let leftOption: PreVoteOption + public let rightOption: PreVoteOption + + public var id: Int { battleId } + + public init( + battleId: Int, + backgroundImageURL: String?, + tags: [String], + titleLine1: String, + titleLine2: String, + summary: String, + leftOption: PreVoteOption, + rightOption: PreVoteOption + ) { + self.battleId = battleId + self.backgroundImageURL = backgroundImageURL + self.tags = tags + self.titleLine1 = titleLine1 + self.titleLine2 = titleLine2 + self.summary = summary + self.leftOption = leftOption + self.rightOption = rightOption + } +} + +public struct PreVoteOption: Equatable, Identifiable, Hashable { + public let philosopher: PhilosopherAvatar + public let stance: String + + public var id: String { philosopher.rawValue } + + public init(philosopher: PhilosopherAvatar, stance: String) { + self.philosopher = philosopher + self.stance = stance + } +} + +/// 사전 투표창 / 새로운 배틀 카드에서 사용되는 철학자 아바타. raw value 는 화면 표시 이름. +public enum PhilosopherAvatar: String, CaseIterable, Equatable, Hashable { + case plato = "플라톤" + case sartre = "사르트르" + case sunja = "순자" +} + +public extension PreVoteBattle { + static let mock = PreVoteBattle( + battleId: 41, + backgroundImageURL: "https://picsum.photos/seed/picke-prevote/750/1024", + tags: ["#예술", "#현대미술"], + titleLine1: "뒤샹의 변기,", + titleLine2: "예술인가 도발인가", + summary: """ + 누군가는 이것을 화장실의 부속품이라 부르고, + 누군가는 현대 미술의 혁명이라고 부릅니다. + 과연 이 변기의 '진짜 모습'은 무엇일까요? + """, + leftOption: .init(philosopher: .plato, stance: "변기는 변기다"), + rightOption: .init(philosopher: .sartre, stance: "예술이다") + ) +} diff --git a/Projects/Domain/Entity/Sources/Poll/PollDetail.swift b/Projects/Domain/Entity/Sources/Poll/PollDetail.swift new file mode 100644 index 0000000..cb81e59 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Poll/PollDetail.swift @@ -0,0 +1,97 @@ +// +// PollDetail.swift +// Entity +// +// `GET /api/v1/polls/{pollId}` 응답에서 사용하는 도메인 모델. +// + +import Foundation + +public struct PollDetail: Equatable, Identifiable { + public let pollId: Int + public let titlePrefix: String + public let titleSuffix: String + public let targetDate: Date? + public let status: PollStatus + public let options: [PollOption] + + public var id: Int { pollId } + + public init( + pollId: Int, + titlePrefix: String, + titleSuffix: String, + targetDate: Date?, + status: PollStatus, + options: [PollOption] + ) { + self.pollId = pollId + self.titlePrefix = titlePrefix + self.titleSuffix = titleSuffix + self.targetDate = targetDate + self.status = status + self.options = options + } +} + +public struct PollOption: Equatable, Identifiable, Hashable { + public let optionId: Int + public let label: String + public let title: String + public let displayOrder: Int + public let voteCount: Int + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String, + title: String, + displayOrder: Int, + voteCount: Int + ) { + self.optionId = optionId + self.label = label + self.title = title + self.displayOrder = displayOrder + self.voteCount = voteCount + } +} + +public enum PollStatus: String, Equatable, Hashable, CaseIterable { + case pending = "PENDING" + case active = "ACTIVE" + case closed = "CLOSED" + case unknown + + public init(rawValue: String) { + self = PollStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} + +public extension PollDetail { + /// 전체 참여 수 = options.voteCount 의 합 + var totalVoteCount: Int { options.reduce(0) { $0 + $1.voteCount } } + + /// 옵션별 비율 (0~100). totalVoteCount 가 0 이면 모두 0. + func percentage(for option: PollOption) -> Int { + guard totalVoteCount > 0 else { return 0 } + return Int((Double(option.voteCount) / Double(totalVoteCount)) * 100) + } +} + +public extension PollDetail { + static let mock = PollDetail( + pollId: 1, + titlePrefix: "도덕의 기준은", + titleSuffix: "이다", + targetDate: nil, + status: .active, + options: [ + .init(optionId: 1, label: "A", title: "결과", displayOrder: 1, voteCount: 45), + .init(optionId: 2, label: "B", title: "의도", displayOrder: 2, voteCount: 25), + .init(optionId: 3, label: "C", title: "규칙", displayOrder: 3, voteCount: 20), + .init(optionId: 4, label: "D", title: "덕", displayOrder: 4, voteCount: 10), + ] + ) +} diff --git a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift index db079ee..c094c4a 100644 --- a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift @@ -25,12 +25,14 @@ public struct AuthUseCaseImpl: AuthInterface { public func login( provider: SocialType, authorizationCode: String, - redirectUri: String + redirectUri: String?, + idToken: String? ) async throws -> LoginEntity { let result = try await authRepository.login( provider: provider, authorizationCode: authorizationCode, - redirectUri: redirectUri + redirectUri: redirectUri, + idToken: idToken ) $userSession.withLock { diff --git a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift index a3355ca..ff4a2e0 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift @@ -85,10 +85,15 @@ public extension UnifiedOAuthUseCase { $0.name = userName } + let authCode = payload.authorizationCode ?? "" + AuthLocalStorage.authCode = authCode + AuthLocalStorage.idToken = payload.idToken + let loginEntity = try await authRepository.login( provider: .apple, - authorizationCode: payload.authorizationCode ?? "", - redirectUri: SocialType.apple.redirectUri + authorizationCode: authCode, + redirectUri: nil, + idToken: payload.idToken ) keychainManager.save( @@ -116,7 +121,8 @@ public extension UnifiedOAuthUseCase { let loginEntity = try await authRepository.login( provider: .google, authorizationCode: payload.authorizationCode ?? "", - redirectUri: payload.redirectUri ?? SocialType.google.redirectUri + redirectUri: payload.redirectUri ?? SocialType.google.redirectUri, + idToken: nil ) keychainManager.save( @@ -142,7 +148,8 @@ public extension UnifiedOAuthUseCase { let loginEntity = try await authRepository.login( provider: .kakao, authorizationCode: payload.authorizationCode ?? "", - redirectUri: payload.redirectUri ?? SocialType.kakao.redirectUri + redirectUri: payload.redirectUri ?? SocialType.kakao.redirectUri, + idToken: nil ) keychainManager.save( diff --git a/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift b/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift index 418bd22..b18d343 100644 --- a/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift +++ b/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift @@ -5,32 +5,30 @@ // Created by Wonji Suh on 5/11/26. // -import SwiftUI import ComposableArchitecture +import SwiftUI import DesignSystem import Entity -public struct LoginView : View { +public struct LoginView: View { @Bindable var store: StoreOf - - - + public var body: some View { ZStack { - Color.neutral50 + Color.gray50 .edgesIgnoringSafeArea(.all) - + VStack { logoView() - + Spacer() .frame(height: 200) - + loginSNSButtonText() - + logjnButton() - + Spacer() .frame(height: UIScreen.screenHeight * 0.12) } @@ -39,46 +37,43 @@ public struct LoginView : View { } } - extension LoginView { @ViewBuilder - private func logoView() -> some View { + private func logoView() -> some View { VStack(alignment: .center) { Spacer() - + Text(" 당신의 생각을") .pretendardCustomFont(textStyle: .headingMedium) .foregroundStyle(.neutral200) - + Image(asset: .loginLogo) .resizable() .scaledToFit() .frame(width: 106, height: 90) } } - + @ViewBuilder private func loginSNSButtonText() -> some View { HStack { Rectangle() - .fill(.borderGray) + .fill(.borderGrayDefault) .frame(width: 64, height: 1) - + Spacer() .frame(width: 12) - + Text("SNS 계정으로 로그인") .pretendardFont(family: .Medium, size: 15) .foregroundStyle(.neutral300) - - + Rectangle() - .fill(.borderGray) + .fill(.borderGrayDefault) .frame(width: 64, height: 1) - } } - + @ViewBuilder private func logjnButton() -> some View { HStack(alignment: .center, spacing: 32) { diff --git a/Projects/Presentation/Auth/Sources/OnBoarding/View/OnBoardingView.swift b/Projects/Presentation/Auth/Sources/OnBoarding/View/OnBoardingView.swift index 043e487..b7f7774 100644 --- a/Projects/Presentation/Auth/Sources/OnBoarding/View/OnBoardingView.swift +++ b/Projects/Presentation/Auth/Sources/OnBoarding/View/OnBoardingView.swift @@ -39,6 +39,7 @@ public struct OnBoardingView: View { extension OnBoardingView { /// 상단: 타이틀 + 서브타이틀 + 일러스트 (페이지 스와이프 지원) + @ViewBuilder private func topSection() -> some View { TabView(selection: $store.currentIndex) { ForEach(OnBoardingFeature.pages) { page in @@ -50,6 +51,7 @@ extension OnBoardingView { .animation(.easeInOut(duration: 0.25), value: store.currentIndex) } + @ViewBuilder private func pageContent(_ page: OnBoardingFeature.Page) -> some View { VStack(spacing: 40) { titleBlock(page) @@ -57,6 +59,7 @@ extension OnBoardingView { } } + @ViewBuilder private func titleBlock(_ page: OnBoardingFeature.Page) -> some View { VStack(spacing: 12) { Text(page.title) @@ -75,6 +78,7 @@ extension OnBoardingView { .padding(.horizontal, 16) } + @ViewBuilder private func illustration(for page: OnBoardingFeature.Page) -> some View { Image(asset: page.imageAsset) .resizable() @@ -83,6 +87,7 @@ extension OnBoardingView { } /// 하단: indicator + CTA 버튼 (Frame 324) + @ViewBuilder private func bottomSection() -> some View { VStack(spacing: 24) { OnBoardingPageIndicator( diff --git a/Projects/Presentation/Home/Sources/Common/PhilosopherAvatar+ImageAsset.swift b/Projects/Presentation/Home/Sources/Common/PhilosopherAvatar+ImageAsset.swift new file mode 100644 index 0000000..0d5ee09 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Common/PhilosopherAvatar+ImageAsset.swift @@ -0,0 +1,20 @@ +// +// PhilosopherAvatar+ImageAsset.swift +// Home +// +// Home 모듈 내부에서만 사용하는 매핑. Entity 가 DesignSystem 에 의존하지 않도록 +// ImageAsset 매핑은 Home 모듈 내부 internal extension 으로 둔다. +// + +import DesignSystem +import Entity + +extension PhilosopherAvatar { + var imageAsset: ImageAsset { + switch self { + case .plato: .avatarPlato + case .sartre: .avatarSartre + case .sunja: .avatarSunja + } + } +} diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 12d2f8a..61bee05 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -56,10 +56,21 @@ public struct HomeCoordinator { extension HomeCoordinator { private func routerAction( - state _: inout State, - action _: IndexedRouterActionOf + state: inout State, + action: IndexedRouterActionOf ) -> Effect { - .none + switch action { + case .routeAction(_, action: .home(.delegate(.presentPreVote))): + state.routes.push(.preVote(.init())) + return .none + + case .routeAction(_, action: .preVote(.delegate(.dismiss))), + .routeAction(_, action: .preVote(.delegate(.submit))): + return .send(.view(.backAction)) + + default: + return .none + } } private func handleViewAction( @@ -81,6 +92,7 @@ extension HomeCoordinator { @Reducer public enum HomeScreen { case home(HomeFeature) + case preVote(PreVoteFeature) } } diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift index d95542f..a109a6d 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -24,6 +24,9 @@ public struct HomeCoordinatorView: View { switch screen.case { case let .home(homeStore): HomeView(store: homeStore) + case let .preVote(preVoteStore): + PreVoteView(store: preVoteStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 4ade8c4..5ea33f4 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -47,6 +47,11 @@ public struct HomeFeature { case onAppear case pullToRefresh case seeMoreTapped(Section) + case voteTapped(VoteQuestion) + case heroTapped(HeroBattle) + case hotBattleTapped(HotBattle) + case bestBattleTapped(BestBattle) + case newBattleTapped(NewBattle) } public enum Section: Equatable { @@ -64,7 +69,9 @@ public struct HomeFeature { case homeResponse(Result) } - public enum DelegateAction: Equatable {} + public enum DelegateAction: Equatable { + case presentPreVote(battleId: Int) + } nonisolated enum CancelID: Hashable { case fetchHome @@ -107,6 +114,21 @@ extension HomeFeature { case .seeMoreTapped: return .none + + case let .voteTapped(question): + return .send(.delegate(.presentPreVote(battleId: question.battleId))) + + case let .heroTapped(battle): + return .send(.delegate(.presentPreVote(battleId: battle.battleId))) + + case let .hotBattleTapped(battle): + return .send(.delegate(.presentPreVote(battleId: battle.battleId))) + + case let .bestBattleTapped(battle): + return .send(.delegate(.presentPreVote(battleId: battle.battleId))) + + case let .newBattleTapped(battle): + return .send(.delegate(.presentPreVote(battleId: battle.battleId))) } } @@ -158,6 +180,9 @@ extension HomeFeature { state _: inout State, action: DelegateAction ) -> Effect { - switch action {} + switch action { + case .presentPreVote: + .none + } } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift index e6fac5b..7ebf9b8 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift @@ -16,8 +16,12 @@ import Kingfisher struct HeroCarouselView: View { let heroes: [HeroBattle] @Binding var currentIndex: Int + var onTap: (HeroBattle) -> Void = { _ in } private static let autoScrollInterval: TimeInterval = 3 + private static let controlHeight: CGFloat = 51 + private static let thumbnailHeight: CGFloat = 220 + private static let subjectHeight: CGFloat = 88 private let timer = Timer.publish(every: autoScrollInterval, on: .main, in: .common).autoconnect() var body: some View { @@ -26,13 +30,17 @@ struct HeroCarouselView: View { HeroCardView( hero: hero, position: index + 1, - total: heroes.count + total: heroes.count, + thumbnailHeight: Self.thumbnailHeight ) + .contentShape(Rectangle()) + .onTapGesture { onTap(hero) } .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(height: 341) // .pen 합: control(53) + thumbnail(167) + subject(121) + .frame(height: Self.controlHeight + Self.thumbnailHeight + Self + .subjectHeight) // .pen 합: control(53) + thumbnail(167) + subject(121) .background(Color.neutral800) .onReceive(timer) { _ in advance() } } @@ -50,18 +58,20 @@ struct HeroCardView: View { let hero: HeroBattle let position: Int let total: Int + let thumbnailHeight: CGFloat var body: some View { VStack(spacing: 0) { - controlRow - thumbnail - subject + controlRow() + thumbnail() + subject() } - .background(Color.neutral800) - .frame(maxWidth: .infinity) + .background(.neutral800) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - private var controlRow: some View { + @ViewBuilder + private func controlRow() -> some View { HStack { Text(hero.badge) .pretendardFont(family: .SemiBold, size: 11) @@ -88,44 +98,55 @@ struct HeroCardView: View { .padding(16) } - private var thumbnail: some View { - ZStack { - if let url = hero.thumbnailURL { - KFImage(url) - .resizable() - .scaledToFill() - .frame(height: 167) - .clipped() - Color.black.opacity(0.4) // .pen 의 "#00000066" 오버레이 - } else { - Color.neutral500.opacity(0.4) - } + @ViewBuilder + private func thumbnail() -> some View { + GeometryReader { proxy in + ZStack { + Rectangle() + .fill(.neutral500.opacity(0.4)) - HStack(spacing: 24) { - Text(hero.optionA) - .pretendardFont(family: .SemiBold, size: 14) - .foregroundStyle(.beige100) - ZStack { - Circle() - .stroke(.secondary50.opacity(0.2), lineWidth: 2) - .frame(width: 32, height: 32) - Text("VS") - .pretendardFont(family: .SemiBold, size: 11) - .foregroundStyle(.secondary50) + if let url = hero.thumbnailURL { + KFImage(url) + .placeholder { SkeletonView() } + .resizable() + .aspectRatio(contentMode: .fill) + .frame( + width: proxy.size.width, + height: proxy.size.height, + alignment: .top + ) + .clipped() } - Text(hero.optionB) - .pretendardFont(family: .SemiBold, size: 14) - .foregroundStyle(.beige100) + + Color.black.opacity(0.4) + + HStack(spacing: 24) { + Text(hero.optionA) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.beige100) + ZStack { + Circle() + .stroke(.secondary50.opacity(0.2), lineWidth: 2) + .frame(width: 32, height: 32) + Text("VS") + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.secondary50) + } + Text(hero.optionB) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.beige100) + } + .opacity(0.85) } - .opacity(0.85) } - .frame(height: 167) + .frame(height: thumbnailHeight) .clipped() } - private var subject: some View { + @ViewBuilder + private func subject() -> some View { HStack(alignment: .bottom) { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 0) { Text(hero.title) .pretendardFont(family: .SemiBold, size: 16) .foregroundStyle(.beige100) @@ -133,6 +154,7 @@ struct HeroCardView: View { .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral200) .lineLimit(2) + HStack(spacing: 4) { ForEach(hero.tags) { tag in Text(tag.name) @@ -140,11 +162,15 @@ struct HeroCardView: View { .foregroundStyle(.neutral200) } } - .padding(.top, 2) + .padding(.top, 6) } + Spacer() - MetaLabelView(systemImage: "eye", text: "\(hero.viewCount)") + + MetaLabelView(systemImage: "eye", text: "\(hero.viewCount.formatted())") } - .padding(20) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 20) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift index df5706c..db34097 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift @@ -33,5 +33,10 @@ struct HomeHeaderView: View { .padding(.vertical, 8) .frame(height: 56) .background(Color.beige50) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeSectionHeader.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HomeSectionHeader.swift index b5388dc..9575096 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HomeSectionHeader.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HomeSectionHeader.swift @@ -16,10 +16,9 @@ struct HomeSectionHeader: View { var body: some View { HStack(spacing: 12) { - Text(title) + Text(attributedTitle) .pretendardFont(family: .Bold, size: 18) .kerning(-0.45) - .foregroundStyle(.neutral900) Spacer(minLength: 0) Button(action: onSeeMoreTapped) { Text("더 보기") @@ -29,4 +28,28 @@ struct HomeSectionHeader: View { } .padding(.horizontal, 16) } + + /// 강조 규칙: + /// - 라틴 단어 (Best · Pické 등) 가 있으면 그 단어만 primary500, 나머지 한글은 neutral900 + /// - 라틴 단어가 없으면 한글 `배틀` 만 primary500, 나머지 한글은 neutral900 + private var attributedTitle: AttributedString { + var attr = AttributedString(title) + attr.foregroundColor = .neutral900 + + let latinPattern = /[A-Za-zÀ-ÿ]+/ + let latinMatches = Array(title.matches(of: latinPattern)) + + if latinMatches.isEmpty { + if let range = attr.range(of: "배틀") { + attr[range].foregroundColor = .primary500 + } + } else { + for match in latinMatches { + if let range = attr.range(of: String(match.0)) { + attr[range].foregroundColor = .primary500 + } + } + } + return attr + } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift index 610abcc..30c78ec 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift @@ -47,6 +47,7 @@ struct HotBattleCardView: View { private var thumbnail: some View { if let url = battle.thumbnailURL { KFImage(url) + .placeholder { SkeletonView() } .resizable() .scaledToFill() .frame(width: 196, height: 124) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift index 0c9e5eb..d7e10fc 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift @@ -12,15 +12,13 @@ import Entity import Kingfisher -/// "새로운 배틀" 리스트 카드 (제목 + VS 아바타 두 개). +/// "새로운 배틀" 리스트 카드 (.pen `Card/BattleListCard` 의 thumbnail 제외 구성). struct NewBattleCardView: View { let battle: NewBattle var body: some View { - VStack(alignment: .leading, spacing: 12) { - headerRow - titleBlock - versusRow + VStack(alignment: .leading, spacing: 8) { + content } .padding(12) .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) @@ -28,13 +26,37 @@ struct NewBattleCardView: View { RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) ) } +} + +// MARK: - Sections + +extension NewBattleCardView { + @ViewBuilder + private var content: some View { + VStack(alignment: .leading, spacing: 16) { + container + versusRow + } + } + + @ViewBuilder + private var container: some View { + VStack(alignment: .leading, spacing: 12) { + metaRow + titleBlock + } + } - private var headerRow: some View { - HStack { + @ViewBuilder + private var metaRow: some View { + HStack(spacing: 10) { if let tag = battle.tags.first { Text(tag.name) - .pretendardFont(family: .Medium, size: 11) + .pretendardFont(family: .SemiBold, size: 12) .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) } Spacer() MetaLabelView(systemImage: "clock", text: "\(battle.durationMinutes)분") @@ -42,30 +64,34 @@ struct NewBattleCardView: View { } } + @ViewBuilder private var titleBlock: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 4) { Text(battle.title) .pretendardFont(family: .SemiBold, size: 14) - .foregroundStyle(.neutral900) + .foregroundStyle(.neutral500) + .kerning(-0.35) .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) Text(battle.summary) .pretendardFont(family: .Medium, size: 12) - .foregroundStyle(.neutral300) + .foregroundStyle(.neutral200) + .lineSpacing(12 * 0.4) .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) } } + @ViewBuilder private var versusRow: some View { HStack(spacing: 8) { - NewBattleAvatarPill( + admissionButton( label: battle.optionATitle, sub: battle.philosopherA, imageURL: battle.philosopherAImageURL ) - Text("VS") - .pretendardFont(family: .SemiBold, size: 11) - .foregroundStyle(.neutral300) - NewBattleAvatarPill( + vsBadge + admissionButton( label: battle.optionBTitle, sub: battle.philosopherB, imageURL: battle.philosopherBImageURL @@ -74,21 +100,24 @@ struct NewBattleCardView: View { } } -/// 새로운 배틀 카드 안의 발화자 아바타 (원형 thumbnail + 라벨). -struct NewBattleAvatarPill: View { - let label: String - let sub: String - let imageURL: URL? +// MARK: - Sub-components - var body: some View { - HStack(spacing: 8) { - avatar - VStack(alignment: .leading, spacing: 0) { +extension NewBattleCardView { + @ViewBuilder + private func admissionButton( + label: String, + sub: String, + imageURL: URL? + ) -> some View { + HStack(spacing: 4) { + avatar(for: sub, imageURL: imageURL) + VStack(alignment: .leading, spacing: 2) { Text(label) - .pretendardFont(family: .SemiBold, size: 12) - .foregroundStyle(.neutral900) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral600) + .kerning(-0.35) Text(sub) - .pretendardFont(family: .Medium, size: 10) + .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral300) } Spacer(minLength: 0) @@ -102,17 +131,33 @@ struct NewBattleAvatarPill: View { } @ViewBuilder - private var avatar: some View { - if let url = imageURL { - KFImage(url) - .resizable() - .scaledToFill() - .frame(width: 28, height: 28) - .clipShape(Circle()) - } else { + private func avatar( + for _: String, + imageURL: URL? + ) -> some View { + // .pen `Avatar/Philosopher` 매핑: 베이지 40×40 원형 배경 + 가운데 16×28 일러스트 + ZStack { Circle() - .fill(.beige500) - .frame(width: 28, height: 28) + .fill(.beige600) + .frame(width: 40, height: 40) + if let imageURL { + KFImage(imageURL) + .placeholder { SkeletonView(cornerRadius: 20) } + .resizable() + .scaledToFit() + .frame(width: 20, height: 38) + } } + .frame(width: 40, height: 40) + } + + @ViewBuilder + private var vsBadge: some View { + Text("VS") + .pretendardFont(family: .Bold, size: 8) + .foregroundStyle(.neutral800) + .frame(width: 24, height: 24) + .background(.secondary200, in: Circle()) + .overlay(Circle().stroke(.beige50, lineWidth: 1.5)) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift index 4eba39b..f1e17dd 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift @@ -4,21 +4,23 @@ // // Created by Wonji Suh on 5/15/26. // +// Pencil .pen `Card/Quiz` — 단일 상태 (Property variant 없음). +// import SwiftUI import DesignSystem import Entity -/// "오늘의 Pické — 퀴즈" 카드. +/// "오늘의 Pické — 퀴즈" 카드. (선택/결과 상태 분리 없음 — .pen 디자인 단일) struct QuizCardView: View { let question: QuizQuestion var body: some View { VStack(alignment: .leading, spacing: 20) { - header - titleBlock - options + header() + titleBlock() + options() } .padding(.vertical, 20) .padding(.horizontal, 16) @@ -28,7 +30,8 @@ struct QuizCardView: View { ) } - private var header: some View { + @ViewBuilder + private func header() -> some View { HStack { TagBadgeView(text: "퀴즈") Spacer() @@ -38,7 +41,8 @@ struct QuizCardView: View { } } - private var titleBlock: some View { + @ViewBuilder + private func titleBlock() -> some View { VStack(alignment: .leading, spacing: 6) { Text(question.title) .pretendardFont(family: .SemiBold, size: 15) @@ -52,13 +56,15 @@ struct QuizCardView: View { } } - private var options: some View { + @ViewBuilder + private func options() -> some View { HStack(spacing: 8) { option(label: question.itemA, desc: question.itemADesc) option(label: question.itemB, desc: question.itemBDesc) } } + @ViewBuilder private func option(label: String, desc: String) -> some View { VStack(spacing: 2) { Text(label) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift index 8337b55..9e0a202 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift @@ -4,7 +4,7 @@ // // Created by Wonji Suh on 5/15/26. // -// Pencil .pen `wZ4Yt` (Card/Vote) 기준으로 1:1 매핑. +// Pencil .pen `wZ4Yt` (Card/Vote) — Property 1=Default · Property 1=Result 두 상태. // import SwiftUI @@ -12,34 +12,51 @@ import SwiftUI import DesignSystem import Entity -/// "오늘의 Pické — 투표" 카드. +/// "오늘의 Pické — 투표" 카드. 옵션 탭 시 result 모드로 전환되어 +/// 빈칸에 선택지 텍스트가 채워지고 옵션 박스 아래에 percentage bar 들이 표시된다. struct VoteCardView: View { let question: VoteQuestion + @State private var selectedIndex: Int? + @State private var animatedFill: Bool = false + + private var isResultMode: Bool { selectedIndex != nil } + + private var selectedLabel: String? { + guard let idx = selectedIndex else { return nil } + return question.options[safe: idx]?.title + } + private let columns = [ GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8), ] + /// API 가 결과 비율을 내려주기 전까지 사용하는 임시 mock 비율. + private static let mockPercentages: [Int] = [45, 25, 20, 10] + var body: some View { VStack(alignment: .leading, spacing: 20) { - header - heading - grid + header() + heading() + grid() + if isResultMode { + resultBars() + } } .padding(.vertical, 20) .padding(.horizontal, 16) .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) .overlay( - RoundedRectangle(cornerRadius: 2).stroke(.beige700, lineWidth: 1) + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) ) } - private var header: some View { + @ViewBuilder + private func header() -> some View { HStack { Text("투표") .pretendardFont(family: .SemiBold, size: 14) - .kerning(-0.35) .foregroundStyle(.primary500) .frame(width: 35, height: 21) .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) @@ -52,56 +69,131 @@ struct VoteCardView: View { } } - private var heading: some View { + @ViewBuilder + private func heading() -> some View { VStack(spacing: 6) { HStack(spacing: 4) { Text(question.titlePrefix) .pretendardFont(family: .SemiBold, size: 15) - .kerning(-0.375) - .foregroundStyle(.neutral900) + .foregroundStyle(.neutral500) - RoundedRectangle(cornerRadius: 2) - .fill(.beige200) - .frame(width: 52, height: 24) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(.beige700, lineWidth: 1) - ) + answerSlot() Text(question.titleSuffix) .pretendardFont(family: .SemiBold, size: 15) - .kerning(-0.375) - .foregroundStyle(.neutral900) + .foregroundStyle(.neutral500) } Text(question.summary) .pretendardFont(family: .Medium, size: 12) - .foregroundStyle(.neutral200) + .foregroundStyle(.neutral300) } .frame(maxWidth: .infinity) } - private var grid: some View { - LazyVGrid(columns: columns, spacing: 8) { + /// 빈칸: 선택 전엔 빈 placeholder, 선택 후엔 선택된 옵션 텍스트 표시. + @ViewBuilder + private func answerSlot() -> some View { + if let label = selectedLabel { + Text(label) + .pretendardFont(family: .SemiBold, size: 15) + .foregroundStyle(.primary500) + .frame(width: 52, height: 24) + .background(.beige200, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.primary500, lineWidth: 1) + ) + } else { + RoundedRectangle(cornerRadius: 2) + .fill(.beige200) + .frame(width: 52, height: 24) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + } + + @ViewBuilder + private func grid() -> some View { + LazyVGrid(columns: columns, spacing: 7) { ForEach(Array(question.options.enumerated()), id: \.offset) { idx, option in - optionButton(index: idx + 1, label: option.title) + optionButton(index: idx, label: option.title) } } } + @ViewBuilder private func optionButton(index: Int, label: String) -> some View { - HStack(spacing: 2) { - Text("\(index).") - .pretendardFont(family: .SemiBold, size: 10) - .foregroundStyle(.secondary900) + let isSelected = selectedIndex == index + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + selectedIndex = isSelected ? nil : index + } + } label: { Text(label) .pretendardFont(family: .SemiBold, size: 13) .foregroundStyle(.neutral900) + .frame(maxWidth: .infinity, minHeight: 44) + .background(.beige400, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(isSelected ? .primary500 : .beige600, lineWidth: isSelected ? 1.5 : 1) + ) } - .frame(maxWidth: .infinity, minHeight: 44) - .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) - .overlay( - RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) - ) + .buttonStyle(.plain) + } + + /// Result mode 옵션 박스 아래 별도 영역 — 4 row (옵션명 + bar + percentage). + /// .pen `Radar Wrap` 디자인을 2-column 으로 재배치. + @ViewBuilder + private func resultBars() -> some View { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(Array(question.options.enumerated()), id: \.offset) { idx, option in + resultBarRow( + label: option.title, + percentage: Self.mockPercentages[safe: idx] ?? 0 + ) + } + } + .padding(.top, 4) + .onAppear { + animatedFill = false + withAnimation(.easeOut(duration: 0.6)) { + animatedFill = true + } + } + .onDisappear { + animatedFill = false + } + } + + @ViewBuilder + private func resultBarRow(label: String, percentage: Int) -> some View { + HStack(spacing: 6) { + Text(label) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.neutral400) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 1) + .fill(.secondary100) + .frame(width: 48, height: 4) + RoundedRectangle(cornerRadius: 1) + .fill(.secondary500) + .frame(width: animatedFill ? 48 * CGFloat(percentage) / 100 : 0, height: 4) + } + Spacer(minLength: 0) + Text("\(percentage)%") + .pretendardFont(family: .Bold, size: 11) + .foregroundStyle(.neutral500) + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift index 82bc2cb..ce7b709 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift @@ -134,6 +134,7 @@ private struct HomeTodayPickeSkeletonView: View { } } + @ViewBuilder private func todayQuizCard() -> some View { VStack(alignment: .leading, spacing: 20) { HStack { @@ -161,6 +162,7 @@ private struct HomeTodayPickeSkeletonView: View { ) } + @ViewBuilder private func todayVoteCard() -> some View { VStack(alignment: .leading, spacing: 20) { HStack { @@ -304,6 +306,7 @@ private struct SkeletonShimmerModifier: ViewModifier { } } + @ViewBuilder private var shimmer: some View { GeometryReader { proxy in LinearGradient( diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 0eb02d5..2aea739 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -31,7 +31,8 @@ public struct HomeView: View { VStack(spacing: 32) { HeroCarouselView( heroes: store.heroes, - currentIndex: $store.heroIndex + currentIndex: $store.heroIndex, + onTap: { send(.heroTapped($0)) } ) hotBattlesSection() @@ -43,7 +44,7 @@ public struct HomeView: View { } } } - .background(Color.beige50.ignoresSafeArea()) + .background(Color.beige200.ignoresSafeArea()) .onAppear { send(.onAppear) } .navigationBarHidden(true) .scrollIndicators(.hidden) @@ -64,6 +65,7 @@ extension HomeView { store.newBattles.isEmpty } + @ViewBuilder private func hotBattlesSection() -> some View { VStack(alignment: .leading, spacing: 12) { HomeSectionHeader(title: "지금 뜨는 배틀") { @@ -71,25 +73,35 @@ extension HomeView { } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { - ForEach(store.hotBattles) { HotBattleCardView(battle: $0) } + ForEach(store.hotBattles) { battle in + HotBattleCardView(battle: battle) + .contentShape(Rectangle()) + .onTapGesture { send(.hotBattleTapped(battle)) } + } } .padding(.horizontal, 16) } } } + @ViewBuilder private func bestBattlesSection() -> some View { VStack(alignment: .leading, spacing: 12) { HomeSectionHeader(title: "Best 배틀") { send(.seeMoreTapped(.bestBattles)) } VStack(spacing: 12) { - ForEach(store.bestBattles) { BestBattleCardView(battle: $0) } + ForEach(store.bestBattles) { battle in + BestBattleCardView(battle: battle) + .contentShape(Rectangle()) + .onTapGesture { send(.bestBattleTapped(battle)) } + } } .padding(.horizontal, 16) } } + @ViewBuilder private func todayPickeSection() -> some View { VStack(alignment: .leading, spacing: 16) { HomeSectionHeader(title: "오늘의 Pické") { @@ -101,19 +113,26 @@ extension HomeView { } if let vote = store.currentVote { VoteCardView(question: vote) + .contentShape(Rectangle()) + .onTapGesture { } } } .padding(.horizontal, 16) } } + @ViewBuilder private func newBattlesSection() -> some View { VStack(alignment: .leading, spacing: 16) { HomeSectionHeader(title: "새로운 배틀") { send(.seeMoreTapped(.newBattles)) } VStack(spacing: 12) { - ForEach(store.newBattles) { NewBattleCardView(battle: $0) } + ForEach(store.newBattles) { battle in + NewBattleCardView(battle: battle) + .contentShape(Rectangle()) + .onTapGesture { send(.newBattleTapped(battle)) } + } } .padding(.horizontal, 16) } diff --git a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift new file mode 100644 index 0000000..9a395e6 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift @@ -0,0 +1,222 @@ +// +// PreVoteFeature.swift +// Home +// +// Created by Wonji Suh on 5/16/26. +// + +import Foundation + +import ComposableArchitecture +import DomainInterface +import Entity +import LogMacro + +@Reducer +public struct PreVoteFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var battle: PreVoteBattle = .mock + public var poll: PollDetail? + public var selectedSide: PhilosopherAvatar? + public var isLoading: Bool = false + public var isSubmitting: Bool = false + public var shareItem: ShareItem? + public var pollId: Int = 1 + + public var isPrimaryButtonEnabled: Bool { + selectedSide != nil && !isSubmitting + } + + public init() {} + } + + /// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩. + public struct ShareItem: Equatable, Identifiable { + public let id: UUID + public let items: [String] + + public init(id: UUID = UUID(), items: [String]) { + self.id = id + self.items = items + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backButtonTapped + case shareTapped + case optionTapped(PhilosopherAvatar) + case primaryButtonTapped + } + + public enum AsyncAction: Equatable { + case fetchPoll + } + + public enum InnerAction: Equatable { + case pollResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + case submit(pollId: Int, side: PhilosopherAvatar) + } + + nonisolated enum CancelID: Hashable { + case fetchPoll + } + + @Dependency(\.pollRepository) private var pollRepository + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + .none + + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension PreVoteFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.poll == nil, !state.isLoading else { return .none } + return .send(.async(.fetchPoll)) + + case .backButtonTapped: + return .send(.delegate(.dismiss)) + + case .shareTapped: + let title = state.poll.map { "\($0.titlePrefix) \($0.titleSuffix)" } + ?? "\(state.battle.titleLine1) \(state.battle.titleLine2)" + state.shareItem = ShareItem( + items: [ + title, + "https://picke.store/poll/\(state.pollId)", + ] + ) + return .none + + case let .optionTapped(side): + state.selectedSide = (state.selectedSide == side) ? nil : side + return .none + + case .primaryButtonTapped: + guard let side = state.selectedSide else { return .none } + state.isSubmitting = true + return .send(.delegate(.submit(pollId: state.pollId, side: side))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchPoll: + state.isLoading = true + let pollId = state.pollId + return .run { [repository = pollRepository] send in + let result = await Result { + try await repository.fetchPoll(pollId: pollId) + } + .mapError(AuthError.from) + return await send(.inner(.pollResponse(result))) + } + .cancellable(id: CancelID.fetchPoll, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .pollResponse(result): + state.isLoading = false + switch result { + case let .success(poll): + state.poll = poll + state.battle = makeBattle(from: poll, fallback: state.battle) + case let .failure(error): + Log.error("[PreVoteFeature] fetchPoll failed: \(error.localizedDescription)") + } + return .none + } + } + + /// API 로 받은 PollDetail 을 화면 모델 PreVoteBattle 로 매핑. + /// background/summary/tags 는 응답에 없으므로 fallback (이전 state.battle) 값을 유지한다. + /// 옵션은 displayOrder 순으로 앞에서부터 2개만 좌/우 카드에 매핑. + private func makeBattle( + from poll: PollDetail, + fallback: PreVoteBattle + ) -> PreVoteBattle { + let philosophers: [PhilosopherAvatar] = [.plato, .sartre, .sunja] + let mapped = poll.options.enumerated().map { idx, option in + PreVoteOption( + philosopher: philosophers[safe: idx] ?? .plato, + stance: option.title + ) + } + let leftOption = mapped[safe: 0] ?? fallback.leftOption + let rightOption = mapped[safe: 1] ?? fallback.rightOption + + return PreVoteBattle( + battleId: poll.pollId, + backgroundImageURL: fallback.backgroundImageURL, + tags: fallback.tags, + titleLine1: poll.titlePrefix, + titleLine2: poll.titleSuffix, + summary: fallback.summary, + leftOption: leftOption, + rightOption: rightOption + ) + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss, .submit: + .none + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift b/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift new file mode 100644 index 0000000..2a6c4ea --- /dev/null +++ b/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift @@ -0,0 +1,73 @@ +// +// PreVoteSkeletonView.swift +// Home +// +// .pen `사전 투표창 - Skeleton Loader` (nTffe) 1:1 매핑. +// PreVoteView 의 로딩 상태 placeholder. +// + +import SwiftUI + +import DesignSystem + +struct PreVoteSkeletonView: View { + private static let designWidth: CGFloat = 375 + private static let designHeight: CGFloat = 812 + + var body: some View { + GeometryReader { proxy in + let scale = proxy.size.width / Self.designWidth + + ZStack(alignment: .topLeading) { + Color.beige50 + + // 상단 이미지 영역 + block(width: 375, height: 329.25, x: 0, y: 0) + + // 헤더 영역 + block(width: 375, height: 60, x: 0, y: 70) + + // 태그 2개 + block(width: 29, height: 17, x: 22, y: 360) + block(width: 49, height: 17, x: 72, y: 360) + + // 타이틀 + block(width: 167, height: 68, x: 16, y: 399) + + // 설명 + block(width: 235.5, height: 61.43, x: 16, y: 479) + + // 좌/우 옵션 카드 + block(width: 167, height: 105.72, x: 14.625, y: 574) + block(width: 167, height: 105.72, x: 193.375, y: 574) + + // VS 작은 점 + block(width: 15, height: 15, x: 373.5, y: 619, cornerRadius: 8) + + // CTA 영역 + block(width: 87, height: 24, x: 144, y: 734) + } + .frame(width: Self.designWidth, height: Self.designHeight, alignment: .topLeading) + .scaleEffect(scale, anchor: .topLeading) + .frame( + width: proxy.size.width, + height: Self.designHeight * scale, + alignment: .topLeading + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + @ViewBuilder + private func block( + width: CGFloat, + height: CGFloat, + x: CGFloat, + y: CGFloat, + cornerRadius: CGFloat = 6 + ) -> some View { + SkeletonView(cornerRadius: cornerRadius) + .frame(width: width, height: height) + .offset(x: x, y: y) + } +} diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift new file mode 100644 index 0000000..9ed57d9 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift @@ -0,0 +1,269 @@ +// +// PreVoteView.swift +// Home +// +// Created by Wonji Suh on 5/16/26. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Kingfisher + +@ViewAction(for: PreVoteFeature.self) +public struct PreVoteView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Group { + if shouldShowSkeleton { + PreVoteSkeletonView() + } else { + loadedContent + } + } + .background(Color.beige50.ignoresSafeArea()) + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + .sheet(item: $store.shareItem) { item in + ShareSheet(items: item.items) + .presentationDetents([.fraction(0.6)]) + .toolbar(.hidden, for: .navigationBar) + } + } + + private var shouldShowSkeleton: Bool { + store.isLoading && store.poll == nil + } + + @ViewBuilder + private var loadedContent: some View { + ZStack(alignment: .top) { + backgroundImage + + VStack(spacing: 0) { + navigationBar + Spacer(minLength: 0) + contentArea + } + } + } +} + +// MARK: - Background + +extension PreVoteView { + @ViewBuilder + private var backgroundImage: some View { + ZStack { + if let urlString = store.battle.backgroundImageURL, + let url = URL(string: urlString) + { + KFImage(url) + .placeholder { SkeletonView() } + .resizable() + .scaledToFill() + } else { + Color.neutral200 + } + + Color.black.opacity(0.4) + } + .frame(height: 512) + .clipped() + .frame(maxWidth: .infinity, alignment: .top) + .ignoresSafeArea(edges: .top) + } +} + +// MARK: - Navigation bar + +extension PreVoteView { + @ViewBuilder + private var navigationBar: some View { + PickeNavigationBar( + onBack: { send(.backButtonTapped) } + ) { + Button { send(.shareTapped) } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16, weight: .semibold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + .foregroundStyle(.beige50) + } +} + +// MARK: - Content (gradient + 카피 + 선택지 + CTA) + +extension PreVoteView { + @ViewBuilder + private var contentArea: some View { + VStack(spacing: 40) { + contentSection + optionSection + primaryButton + } + .padding(.horizontal, 16) + .padding(.top, 80) + .padding(.bottom, 40) + .background( + LinearGradient( + stops: [ + .init(color: Color.beige50.opacity(0), location: 0), + .init(color: .beige50, location: 0.35), + .init(color: .beige50, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .ignoresSafeArea(edges: .bottom) + } + + @ViewBuilder + private var contentSection: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 20) { + tagsRow + titleText + } + summaryText + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var tagsRow: some View { + HStack(spacing: 9) { + ForEach(store.battle.tags, id: \.self) { tag in + Text(tag) + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + } + } + + @ViewBuilder + private var titleText: some View { + Text("\(store.battle.titleLine1)\n\(store.battle.titleLine2)") + .pretendardFont(family: .Bold, size: 24) + .foregroundStyle(.neutral500) + .kerning(-0.6) + .lineSpacing(24 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var summaryText: some View { + Text(store.battle.summary) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - 선택지 + +extension PreVoteView { + @ViewBuilder + private var optionSection: some View { + ZStack { + HStack(spacing: 8) { + optionCard(store.battle.leftOption) + optionCard(store.battle.rightOption) + } + vsBadge + } + } + + @ViewBuilder + private func optionCard(_ option: PreVoteOption) -> some View { + let isSelected = store.selectedSide == option.philosopher + + return Button { + send(.optionTapped(option.philosopher)) + } label: { + VStack(spacing: 12) { + avatarView(option.philosopher) + + VStack(spacing: 2) { + Text(option.stance) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral600) + .kerning(-0.35) + .multilineTextAlignment(.center) + + Text(option.philosopher.rawValue) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral300) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(8) + .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(isSelected ? .primary500 : .beige600, lineWidth: isSelected ? 1.5 : 1) + ) + .opacity(isSelected ? 1.0 : 0.88) + } + .buttonStyle(.plain) + } + + private func avatarView(_ philosopher: PhilosopherAvatar) -> some View { + Image(asset: philosopher.imageAsset) + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + .background(.beige600, in: Circle()) + } + + @ViewBuilder + private var vsBadge: some View { + Text("VS") + .pretendardFont(family: .Bold, size: 11) + .foregroundStyle(.neutral800) + .frame(width: 28, height: 28) + .background(.secondary200, in: Circle()) + .overlay(Circle().stroke(.beige50, lineWidth: 1.5)) + } +} + +// MARK: - CTA + +extension PreVoteView { + @ViewBuilder + private var primaryButton: some View { + CustomButton( + action: { send(.primaryButtonTapped) }, + title: "사전 투표하기", + config: CustomButtonConfig.primary(.large, height: 52), + isEnable: store.isPrimaryButtonEnabled + ) + } +} + +#Preview { + PreVoteView( + store: Store(initialState: PreVoteFeature.State()) { + PreVoteFeature() + } + ) +} diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift index 071ec78..d76465f 100644 --- a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -35,13 +35,13 @@ public struct MainTabCoordinator { } } - /// 디자인 시스템 GNB 아이콘 (Pencil 추출 PNG) - public var iconAsset: ImageAsset { + /// 디자인 시스템 GNB 아이콘 (탭바 아이콘 폴더 PNG, single-scale) + public func iconAsset(isSelected: Bool) -> ImageAsset { switch self { - case .home: .tabHome - case .explore: .tabExplore - case .quickBattle: .tabQuickBattle - case .myPage: .tabMyPage + case .home: isSelected ? .tabHomeActive : .tabHome + case .explore: isSelected ? .tabExploreActive : .tabExplore + case .quickBattle: isSelected ? .tabQuickBattleActive : .tabQuickBattle + case .myPage: isSelected ? .tabMyPageActive : .tabMyPage } } } diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift index 8fd62c3..a846aab 100644 --- a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -26,7 +26,7 @@ public struct MainTabView: View { TCAFlowTabRouter( selectedTab: $store.selectedTab.sending(\.selectTab), tabs: MainTabCoordinator.Tab.allCases.map { - TabItem(title: $0.title, icon: $0.iconAsset.rawValue, tag: $0.rawValue) + TabItem(title: $0.title, icon: $0.iconAsset(isSelected: false).rawValue, tag: $0.rawValue) }, onReselect: { tab in store.send(.tabReselected(tab)) @@ -73,16 +73,17 @@ extension MainTabView { itemAppearance.normal.iconColor = normalColor itemAppearance.normal.titleTextAttributes = [ .font: font, - .foregroundColor: normalColor + .foregroundColor: normalColor, ] itemAppearance.selected.iconColor = selectedColor itemAppearance.selected.titleTextAttributes = [ .font: font, - .foregroundColor: selectedColor + .foregroundColor: selectedColor, ] } + @ViewBuilder private func tabLabel(for tab: TabItem) -> some View { Label { Text(tab.title) @@ -94,12 +95,15 @@ extension MainTabView { @ViewBuilder private func tabIcon(for tab: TabItem) -> some View { - if let image = UIImage(assetName: tab.icon)?.withRenderingMode(.alwaysTemplate) { - Image(uiImage: image) - .renderingMode(.template) - } else { - Image(systemName: "questionmark") - } + let isSelected = store.selectedTab == tab.tag + let asset = MainTabCoordinator.Tab(rawValue: tab.tag)? + .iconAsset(isSelected: isSelected) ?? .none + + Image(asset: asset) + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) } @ViewBuilder diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/Contents.json new file mode 100644 index 0000000..7659a81 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "avatarPlato.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/avatarPlato.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/avatarPlato.png new file mode 100644 index 0000000..ebc2951 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarPlato.imageset/avatarPlato.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/Contents.json new file mode 100644 index 0000000..8be55fb --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "avatarSartre.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/avatarSartre.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/avatarSartre.png new file mode 100644 index 0000000..54c35ec Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSartre.imageset/avatarSartre.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/Contents.json new file mode 100644 index 0000000..2ff5c82 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "avatarSunja.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/avatarSunja.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/avatarSunja.png new file mode 100644 index 0000000..af91a74 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Avatar/avatarSunja.imageset/avatarSunja.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json index 1fab2f6..61f2414 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json @@ -1,12 +1,16 @@ { "images" : [ { - "filename" : "Union.png", + "filename" : "tabExplore.svg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" } } diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Union.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Union.png deleted file mode 100644 index d5b739e..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Union.png and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/tabExplore.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/tabExplore.svg new file mode 100644 index 0000000..98e1613 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/tabExplore.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/Contents.json new file mode 100644 index 0000000..65fe5dc --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "tabExploreActive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/tabExploreActive.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/tabExploreActive.svg new file mode 100644 index 0000000..e9174b2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExploreActive.imageset/tabExploreActive.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json index 1c2ff97..3340058 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json @@ -1,12 +1,16 @@ { "images" : [ { - "filename" : "Icon.svg", + "filename" : "tabHome.svg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" } } diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Icon.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Icon.svg deleted file mode 100644 index 5523ab2..0000000 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/tabHome.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/tabHome.svg new file mode 100644 index 0000000..0d2bc07 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/tabHome.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/Contents.json new file mode 100644 index 0000000..d19457d --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "tabHomeActive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/tabHomeActive.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/tabHomeActive.svg new file mode 100644 index 0000000..5e8ae77 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHomeActive.imageset/tabHomeActive.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json index 5cf58fd..c92b999 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json @@ -1,12 +1,16 @@ { "images" : [ { - "filename" : "Group 27.svg", + "filename" : "tabMyPage.svg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" } } diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Group 27.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Group 27.svg deleted file mode 100644 index 82f9d72..0000000 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Group 27.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/tabMyPage.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/tabMyPage.svg new file mode 100644 index 0000000..16ca7ef --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/tabMyPage.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/Contents.json new file mode 100644 index 0000000..9ab58b6 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "tabMyPageActive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/tabMyPageActive.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/tabMyPageActive.svg new file mode 100644 index 0000000..2fb9b1c --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPageActive.imageset/tabMyPageActive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json index 1c2ff97..44915c7 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json @@ -1,12 +1,16 @@ { "images" : [ { - "filename" : "Icon.svg", + "filename" : "tabQuickBattle.svg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" } } diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/tabQuickBattle.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/tabQuickBattle.svg new file mode 100644 index 0000000..27b376a --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/tabQuickBattle.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/Contents.json new file mode 100644 index 0000000..c65b07d --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "tabQuickBattleActive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Icon.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/tabQuickBattleActive.svg similarity index 100% rename from Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Icon.svg rename to Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattleActive.imageset/tabQuickBattleActive.svg diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json index a1a7abc..4955878 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지.svg", + "filename" : "이미지.png", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.png" new file mode 100644 index 0000000..4b99e80 Binary files /dev/null and "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.png" differ diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.svg" deleted file mode 100644 index 32f7c27..0000000 --- "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.svg" +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json index a1a7abc..31857c8 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지.svg", + "filename" : "이미지 1.png", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200 1.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200 1.png" new file mode 100644 index 0000000..4d1a992 Binary files /dev/null and "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200 1.png" differ diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200.svg" deleted file mode 100644 index 8c0375e..0000000 --- "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200.svg" +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json index a1a7abc..4955878 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지.svg", + "filename" : "이미지.png", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.png" new file mode 100644 index 0000000..fa6e43a Binary files /dev/null and "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.png" differ diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.svg" deleted file mode 100644 index c4c3eb9..0000000 --- "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.svg" +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json b/Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json deleted file mode 100644 index fa1e048..0000000 --- a/Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json +++ /dev/null @@ -1,1675 +0,0 @@ -{ - "Colors": { - "brand": { - "primary": { - "50": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9529411792755127, - 0.9215686321258545, - 0.9137254953384399 - ], - "alpha": 1, - "hex": "#F3EBE9" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7232", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "100": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9058823585510254, - 0.843137264251709, - 0.8274509906768799 - ], - "alpha": 1, - "hex": "#E7D7D3" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7228", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "200": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.8156862854957581, - 0.686274528503418, - 0.658823549747467 - ], - "alpha": 1, - "hex": "#D0AFA8" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7229", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "300": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.7215686440467834, - 0.5333333611488342, - 0.48627451062202454 - ], - "alpha": 1, - "hex": "#B8887C" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7236", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "400": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.6313725709915161, - 0.3764705955982208, - 0.3176470696926117 - ], - "alpha": 1, - "hex": "#A16051" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7230", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "500": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.5372549295425415, - 0.21960784494876862, - 0.14509804546833038 - ], - "alpha": 1, - "hex": "#893825" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7234", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "600": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.47843137383461, - 0.21176470816135406, - 0.14901961386203766 - ], - "alpha": 1, - "hex": "#7A3626" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7231", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "700": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.4431372582912445, - 0.21568627655506134, - 0.16470588743686676 - ], - "alpha": 1, - "hex": "#71372A" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7233", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "800": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.3960784375667572, - 0.19607843458652496, - 0.14901961386203766 - ], - "alpha": 1, - "hex": "#653226" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7235", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "900": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.30588236451148987, - 0.16470588743686676, - 0.12941177189350128 - ], - "alpha": 1, - "hex": "#4E2A21" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7237", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "Alpha": { - "8": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.5372549295425415, - 0.21960784494876862, - 0.14509804546833038 - ], - "alpha": 0.07999999821186066, - "hex": "#893825" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6766:7483", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - } - }, - "secondary": { - "50": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9882352948188782, - 0.9725490212440491, - 0.9450980424880981 - ], - "alpha": 1, - "hex": "#FCF8F1" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7292", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "100": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9764705896377563, - 0.9450980424880981, - 0.8901960849761963 - ], - "alpha": 1, - "hex": "#F9F1E3" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7293", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "200": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9529411792755127, - 0.8901960849761963, - 0.7803921699523926 - ], - "alpha": 1, - "hex": "#F3E3C7" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7294", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "300": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.929411768913269, - 0.8352941274642944, - 0.6745098233222961 - ], - "alpha": 1, - "hex": "#EDD5AC" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7295", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "400": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9058823585510254, - 0.7803921699523926, - 0.5647059082984924 - ], - "alpha": 1, - "hex": "#E7C790" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7300", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "500": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.8823529481887817, - 0.7254902124404907, - 0.45490196347236633 - ], - "alpha": 1, - "hex": "#E1B974" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7296", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "600": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.8076171875, - 0.66231369972229, - 0.411665141582489 - ], - "alpha": 1, - "hex": "#CEA969" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7297", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "700": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.7186185121536255, - 0.5878713130950928, - 0.3623323142528534 - ], - "alpha": 1, - "hex": "#B7965C" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7298", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "800": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.6392157077789307, - 0.5254902243614197, - 0.32549020648002625 - ], - "alpha": 1, - "hex": "#A38653" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7299", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "900": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.572549045085907, - 0.47058823704719543, - 0.29019609093666077 - ], - "alpha": 1, - "hex": "#92784A" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7301", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "beige": { - "50": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9960784316062927, - 0.9960784316062927, - 0.9921568632125854 - ], - "alpha": 1, - "hex": "#FEFEFD" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7325", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "100": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9921568632125854, - 0.9882352948188782, - 0.9843137264251709 - ], - "alpha": 1, - "hex": "#FDFCFB" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7324", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "200": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9843137264251709, - 0.9764705896377563, - 0.9686274528503418 - ], - "alpha": 1, - "hex": "#FBF9F7" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7326", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "300": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9764705896377563, - 0.9686274528503418, - 0.9490196108818054 - ], - "alpha": 1, - "hex": "#F9F7F2" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7327", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "400": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9686274528503418, - 0.95686274766922, - 0.9333333373069763 - ], - "alpha": 1, - "hex": "#F7F4EE" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7328", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "500": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9607843160629272, - 0.9450980424880981, - 0.9176470637321472 - ], - "alpha": 1, - "hex": "#F5F1EA" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7333", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "600": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9372549057006836, - 0.9176470637321472, - 0.8784313797950745 - ], - "alpha": 1, - "hex": "#EFEAE0" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7329", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "700": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.8549019694328308, - 0.8196078538894653, - 0.7490196228027344 - ], - "alpha": 1, - "hex": "#DAD1BF" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7332", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "800": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.8078431487083435, - 0.7568627595901489, - 0.658823549747467 - ], - "alpha": 1, - "hex": "#CEC1A8" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7331", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "900": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.7176470756530762, - 0.658823549747467, - 0.545098066329956 - ], - "alpha": 1, - "hex": "#B7A88B" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7330", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "neutral": { - "50": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9215686321258545, - 0.9215686321258545, - 0.9215686321258545 - ], - "alpha": 1, - "hex": "#EBEBEB" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7356", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "100": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.843137264251709, - 0.843137264251709, - 0.843137264251709 - ], - "alpha": 1, - "hex": "#D7D7D7" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7363", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "200": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.6901960968971252, - 0.686274528503418, - 0.6823529601097107 - ], - "alpha": 1, - "hex": "#B0AFAE" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7357", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "300": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.5333333611488342, - 0.529411792755127, - 0.5254902243614197 - ], - "alpha": 1, - "hex": "#888786" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7358", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "400": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.3803921639919281, - 0.37254902720451355, - 0.364705890417099 - ], - "alpha": 1, - "hex": "#615F5D" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7359", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "500": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.2235294133424759, - 0.21568627655506134, - 0.2078431397676468 - ], - "alpha": 1, - "hex": "#393735" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7360", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "600": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.16862745583057404, - 0.16470588743686676, - 0.1568627506494522 - ], - "alpha": 1, - "hex": "#2B2A28" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7362", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "700": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.13333334028720856, - 0.12941177189350128, - 0.125490203499794 - ], - "alpha": 1, - "hex": "#222120" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7364", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "800": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.10196078568696976, - 0.09803921729326248, - 0.0941176488995552 - ], - "alpha": 1, - "hex": "#1A1918" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7365", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "900": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.07450980693101883, - 0.07058823853731155, - 0.07058823853731155 - ], - "alpha": 1, - "hex": "#131212" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7361", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - } - }, - "semantic": { - "text": { - "primary": { - "$type": "color", - "$value": "{Colors.brand.neutral.900}", - "$extensions": { - "com.figma.variableId": "VariableID:6708:7367", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "secondary": { - "$type": "color", - "$value": "{Colors.brand.neutral.700}", - "$extensions": { - "com.figma.variableId": "VariableID:6708:7369", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "tertiary": { - "$type": "color", - "$value": "{Colors.brand.neutral.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6708:7370", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "muted": { - "$type": "color", - "$value": "{Colors.brand.neutral.300}", - "$extensions": { - "com.figma.variableId": "VariableID:6708:7371", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "inverse": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9960784316062927, - 0.9960784316062927, - 0.9921568632125854 - ], - "alpha": 1, - "hex": "#FEFEFD" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7372", - "com.figma.scopes": [ - "ALL_SCOPES" - ], - "com.figma.aliasData": { - "targetVariableId": "VariableID:5ca37bec2680585542952367424f7ecf45490cc3/-1:-1", - "targetVariableName": "beige color50", - "targetVariableSetId": "VariableCollectionId:bbcfac25774af1795702717ddc0dd55de8fd3823/-1:-1", - "targetVariableSetName": "beige color" - } - } - }, - "brand": { - "$type": "color", - "$value": "{Colors.brand.primary.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6708:7373", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "border": { - "default": { - "$type": "color", - "$value": "{Colors.brand.beige.600}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7399", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "subtle": { - "$type": "color", - "$value": "{Colors.brand.beige.700}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7400", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "disabled": { - "$type": "color", - "$value": "{Colors.brand.beige.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7401", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "selected": { - "$type": "color", - "$value": "{Colors.brand.secondary.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7434", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "strong": { - "$type": "color", - "$value": "{Colors.brand.primary.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7467", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "focus": { - "$type": "color", - "$value": "{Colors.brand.beige.700}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7468", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "error": { - "$type": "color", - "$value": "{Colors.semantic.status.error.Alpha}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7469", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "surface": { - "default": { - "$type": "color", - "$value": "{Colors.brand.beige.50}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7387", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "subtle": { - "$type": "color", - "$value": "{Colors.brand.beige.300}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7388", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "tertiary": { - "$type": "color", - "$value": "{Colors.brand.beige.400}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7389", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "selected": { - "$type": "color", - "$value": "{Colors.brand.primary.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7390", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "disabled": { - "$type": "color", - "$value": "{Colors.brand.primary.200}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7391", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "background": { - "default": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9803921580314636, - 0.9803921580314636, - 0.9764705896377563 - ], - "alpha": 1, - "hex": "#FAFAF9" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7382", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "subtle": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.9607843160629272, - 0.9607843160629272, - 0.95686274766922 - ], - "alpha": 1, - "hex": "#F5F5F4" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7383", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "tertiary": { - "$type": "color", - "$value": "{Colors.brand.neutral.50}", - "$extensions": { - "com.figma.variableId": "VariableID:6715:7398", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "brand": { - "$type": "color", - "$value": "{Colors.brand.beige.200}", - "$extensions": { - "com.figma.variableId": "VariableID:6708:7384", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "inverse": { - "$type": "color", - "$value": "{Colors.brand.neutral.800}", - "$extensions": { - "com.figma.variableId": "VariableID:6708:7386", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "overlay": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0, - 0, - 0 - ], - "alpha": 0.4000000059604645, - "hex": "#000000" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6715:7392", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "status": { - "error": { - "error": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.7882353067398071, - 0.1764705926179886, - 0.20000000298023224 - ], - "alpha": 1, - "hex": "#C92D33" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7376", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "Alpha": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.7882353067398071, - 0.1764705926179886, - 0.20000000298023224 - ], - "alpha": 0.4000000059604645, - "hex": "#C92D33" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6761:657", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "warning": { - "warning": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 1, - 0.7058823704719543, - 0 - ], - "alpha": 1, - "hex": "#FFB400" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6708:7380", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "Alpha": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 1, - 0.7058823704719543, - 0 - ], - "alpha": 0.4000000059604645, - "hex": "#FFB400" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6761:658", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - } - } - } - }, - "Radius": { - "none": { - "$type": "number", - "$value": 0, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7000", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "default": { - "$type": "number", - "$value": 2, - "$extensions": { - "com.figma.variableId": "VariableID:6761:6998", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "full": { - "$type": "number", - "$value": 999, - "$extensions": { - "com.figma.variableId": "VariableID:6761:6999", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "Spacing": { - "0": { - "$type": "number", - "$value": 0, - "$extensions": { - "com.figma.variableId": "VariableID:6762:6955", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "2": { - "$type": "number", - "$value": 2, - "$extensions": { - "com.figma.variableId": "VariableID:6762:6956", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "4": { - "$type": "number", - "$value": 4, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7107", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "8": { - "$type": "number", - "$value": 8, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7111", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "16": { - "$type": "number", - "$value": 16, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7110", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "24": { - "$type": "number", - "$value": 24, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7108", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "32": { - "$type": "number", - "$value": 32, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7112", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "40": { - "$type": "number", - "$value": 40, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7104", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "48": { - "$type": "number", - "$value": 48, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7103", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "64": { - "$type": "number", - "$value": 64, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7109", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "80": { - "$type": "number", - "$value": 80, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7106", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "96": { - "$type": "number", - "$value": 96, - "$extensions": { - "com.figma.variableId": "VariableID:6761:7105", - "com.figma.hiddenFromPublishing": true, - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "Component": { - "bedge": { - "filled": { - "background": { - "default": { - "$type": "color", - "$value": "{Colors.brand.beige.600}", - "$extensions": { - "com.figma.variableId": "VariableID:6792:1209", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "inverse": { - "$type": "color", - "$value": "{Colors.brand.primary.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6904:2076", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "text": { - "default": { - "$type": "color", - "$value": "{Colors.brand.primary.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6904:2078", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "inverse": { - "$type": "color", - "$value": "{Colors.brand.beige.50}", - "$extensions": { - "com.figma.variableId": "VariableID:6904:2082", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - } - }, - "outline": { - "text": { - "$type": "color", - "$value": "{Colors.brand.primary.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6872:7386", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "backround": { - "$type": "color", - "$value": "{Colors.brand.beige.50}", - "$extensions": { - "com.figma.variableId": "VariableID:6904:2067", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "border": { - "$type": "color", - "$value": "{Colors.brand.primary.100}", - "$extensions": { - "com.figma.variableId": "VariableID:6904:2071", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - } - }, - "input": { - "border": { - "default": { - "$type": "color", - "$value": "{Colors.brand.beige.600}", - "$extensions": { - "com.figma.variableId": "VariableID:6792:1210", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "active": { - "$type": "color", - "$value": "{Colors.brand.beige.700}", - "$extensions": { - "com.figma.variableId": "VariableID:6857:7360", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "error": { - "$type": "color", - "$value": "{Colors.semantic.border.error}", - "$extensions": { - "com.figma.variableId": "VariableID:6857:7361", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "surface": { - "default": { - "$type": "color", - "$value": "{Colors.brand.beige.50}", - "$extensions": { - "com.figma.variableId": "VariableID:6857:7362", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "disabled": { - "$type": "color", - "$value": "{Colors.brand.beige.300}", - "$extensions": { - "com.figma.variableId": "VariableID:6857:7363", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "text": { - "default": { - "$type": "color", - "$value": "{Colors.brand.neutral.300}", - "$extensions": { - "com.figma.variableId": "VariableID:6857:7367", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "active": { - "$type": "color", - "$value": "{Colors.brand.neutral.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6857:7380", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "error": { - "$type": "color", - "$value": "{Colors.semantic.status.error.error}", - "$extensions": { - "com.figma.variableId": "VariableID:6857:7371", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - } - }, - "button": { - "radius": { - "$type": "number", - "$value": "{Radius.default}", - "$extensions": { - "com.figma.variableId": "VariableID:6854:7375", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "primary": { - "background": { - "default": { - "$type": "color", - "$value": "{Colors.brand.primary.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6792:1208", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "pressed": { - "$type": "color", - "$value": "{Colors.brand.primary.800}", - "$extensions": { - "com.figma.variableId": "VariableID:6829:7354", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "disabled": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.8156862854957581, - 0.686274528503418, - 0.658823549747467 - ], - "alpha": 1, - "hex": "#D0AFA8" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6829:7355", - "com.figma.scopes": [ - "ALL_SCOPES" - ], - "com.figma.aliasData": { - "targetVariableId": "VariableID:cfdbc8ce010b4bf46c1aa1aebabc7686775c41ce/-1:-1", - "targetVariableName": "Primary200", - "targetVariableSetId": "VariableCollectionId:054680f45b5bcc4599f8f5dbddcbb91b1f17bca3/-1:-1", - "targetVariableSetName": "primary color" - } - } - } - }, - "text": { - "default": { - "$type": "color", - "$value": "{Colors.brand.beige.50}", - "$extensions": { - "com.figma.variableId": "VariableID:6829:7367", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - } - }, - "secondary": { - "background": { - "default": { - "$type": "color", - "$value": "{Colors.brand.beige.300}", - "$extensions": { - "com.figma.variableId": "VariableID:6841:7370", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "pressed": { - "$type": "color", - "$value": "{Colors.brand.beige.400}", - "$extensions": { - "com.figma.variableId": "VariableID:6904:2583", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "border": { - "default": { - "$type": "color", - "$value": "{Colors.brand.beige.600}", - "$extensions": { - "com.figma.variableId": "VariableID:6841:7373", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "pressed": { - "$type": "color", - "$value": "{Colors.brand.secondary.500}", - "$extensions": { - "com.figma.variableId": "VariableID:6841:7374", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - } - }, - "text": { - "default": { - "$type": "color", - "$value": "{Colors.brand.neutral.600}", - "$extensions": { - "com.figma.variableId": "VariableID:6854:7377", - "com.figma.scopes": [ - "ALL_SCOPES" - ] - } - }, - "disabled": { - "$type": "color", - "$value": { - "colorSpace": "srgb", - "components": [ - 0.5333333611488342, - 0.529411792755127, - 0.5254902243614197 - ], - "alpha": 1, - "hex": "#888786" - }, - "$extensions": { - "com.figma.variableId": "VariableID:6904:2584", - "com.figma.scopes": [ - "ALL_SCOPES" - ], - "com.figma.aliasData": { - "targetVariableId": "VariableID:e9a3ae05dbe56ce69dee81ab24ef760e560eede8/-1:-1", - "targetVariableName": "gray300", - "targetVariableSetId": "VariableCollectionId:58e4e447eaf6a6da4ebd7c821fb97c415c00f391/-1:-1", - "targetVariableSetName": "gray color" - } - } - } - } - } - } - }, - "$extensions": { - "com.figma.modeName": "Mode 1" - } -} \ No newline at end of file diff --git a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift index 499baa8..500e283 100644 --- a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift +++ b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift @@ -1,11 +1,11 @@ // AUTO-GENERATED by Tools/TokenGenerator.swift — DO NOT EDIT -// Source: Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json +// Source: SWYP-Find/design-tokens repo (primitive.json / semantic.json / component.json) import SwiftUI public extension ShapeStyle where Self == Color { - // MARK: - Brand / Primary + // MARK: - Primitive / Primary static var primary50: Color { .init(hex: "F3EBE9") } static var primary100: Color { .init(hex: "E7D7D3") } static var primary200: Color { .init(hex: "D0AFA8") } @@ -16,9 +16,8 @@ public extension ShapeStyle where Self == Color { static var primary700: Color { .init(hex: "71372A") } static var primary800: Color { .init(hex: "653226") } static var primary900: Color { .init(hex: "4E2A21") } - static var primaryAlpha8: Color { .init(hex: "893825", alpha: 0.08) } - // MARK: - Brand / Secondary + // MARK: - Primitive / Secondary static var secondary50: Color { .init(hex: "FCF8F1") } static var secondary100: Color { .init(hex: "F9F1E3") } static var secondary200: Color { .init(hex: "F3E3C7") } @@ -30,7 +29,7 @@ public extension ShapeStyle where Self == Color { static var secondary800: Color { .init(hex: "A38653") } static var secondary900: Color { .init(hex: "92784A") } - // MARK: - Brand / Beige + // MARK: - Primitive / Beige static var beige50: Color { .init(hex: "FEFEFD") } static var beige100: Color { .init(hex: "FDFCFB") } static var beige200: Color { .init(hex: "FBF9F7") } @@ -42,82 +41,166 @@ public extension ShapeStyle where Self == Color { static var beige800: Color { .init(hex: "CEC1A8") } static var beige900: Color { .init(hex: "B7A88B") } - // MARK: - Brand / Neutral - static var neutral50: Color { .init(hex: "EBEBEB") } - static var neutral100: Color { .init(hex: "D7D7D7") } - static var neutral200: Color { .init(hex: "B0AFAE") } - static var neutral300: Color { .init(hex: "888786") } - static var neutral400: Color { .init(hex: "615F5D") } - static var neutral500: Color { .init(hex: "393735") } - static var neutral600: Color { .init(hex: "2B2A28") } - static var neutral700: Color { .init(hex: "222120") } - static var neutral800: Color { .init(hex: "1A1918") } - static var neutral900: Color { .init(hex: "131212") } + // MARK: - Primitive / Gray + static var gray50: Color { .init(hex: "EBEBEB") } + static var gray100: Color { .init(hex: "D7D7D7") } + static var gray200: Color { .init(hex: "B0AFAE") } + static var gray300: Color { .init(hex: "888786") } + static var gray400: Color { .init(hex: "615F5D") } + static var gray500: Color { .init(hex: "393735") } + static var gray600: Color { .init(hex: "2B2A28") } + static var gray700: Color { .init(hex: "222120") } + static var gray800: Color { .init(hex: "1A1918") } + static var gray900: Color { .init(hex: "131212") } + + // MARK: - Compat / Neutral (alias of Gray) + static var neutral50: Color { .gray50 } + static var neutral100: Color { .gray100 } + static var neutral200: Color { .gray200 } + static var neutral300: Color { .gray300 } + static var neutral400: Color { .gray400 } + static var neutral500: Color { .gray500 } + static var neutral600: Color { .gray600 } + static var neutral700: Color { .gray700 } + static var neutral800: Color { .gray800 } + static var neutral900: Color { .gray900 } + + // MARK: - Primitive / Status + static var errorAlpha: Color { .init(hex: "C92D33", alpha: 0.4) } + static var errorDefault: Color { .init(hex: "C92D33") } + static var warningDefault: Color { .init(hex: "FFB400") } + static var warningAlpha: Color { .init(hex: "FFB400", alpha: 0.4) } + + // MARK: - Semantic / Background + static var bgBeige: Color { .beige200 } + static var bgDefault: Color { .init(hex: "FAFAF9") } + static var bgSubtle: Color { .init(hex: "F5F5F4") } + static var bgSubtler: Color { .gray50 } // MARK: - Semantic / Text - static var textBrand: Color { .primary500 } - static var textInverse: Color { .init(hex: "FEFEFD") } - static var textMuted: Color { .neutral300 } - static var textPrimary: Color { .neutral900 } - static var textSecondary: Color { .neutral700 } - static var textTertiary: Color { .neutral500 } + static var textBody: Color { .gray400 } + static var textDefault: Color { .gray800 } + static var textError: Color { .errorDefault } + static var textInverse: Color { .beige50 } + static var textMuted: Color { .gray300 } + static var textPrimary: Color { .primary500 } + static var textSecondary: Color { .secondary500 } + static var textSubtle: Color { .gray700 } + static var textSubtler: Color { .gray500 } // MARK: - Semantic / Border - static var borderDefault: Color { .beige600 } - static var borderDisabled: Color { .beige500 } - static var borderError: Color { .statusErrorAlpha } - static var borderFocus: Color { .beige700 } - static var borderSelected: Color { .secondary500 } - static var borderStrong: Color { .primary500 } - static var borderSubtle: Color { .beige700 } + static var borderBeigeDefault: Color { .beige600 } + static var borderBeigeDisabled: Color { .beige500 } + static var borderBeigeFocus: Color { .beige700 } + static var borderBeigeSelected: Color { .beige700 } + static var borderErrorDefault: Color { .init(hex: "C92D33", alpha: 0.4) } + static var borderGrayDefault: Color { .gray100 } + static var borderGraySubtle: Color { .gray50 } + static var borderPrimaryDefault: Color { .primary500 } + static var borderSecondarySelected: Color { .secondary500 } + static var borderWarningDefault: Color { .init(hex: "FFB400", alpha: 0.4) } // MARK: - Semantic / Surface - static var surfaceDefault: Color { .beige50 } - static var surfaceDisabled: Color { .primary200 } - static var surfaceSelected: Color { .primary500 } - static var surfaceSubtle: Color { .beige300 } - static var surfaceTertiary: Color { .beige400 } + static var surfaceBeigeDefault: Color { .beige50 } + static var surfaceBeigeStrong: Color { .beige400 } + static var surfaceBeigeSubtle: Color { .beige300 } + static var surfacePrimaryDefault: Color { .primary500 } + static var surfacePrimarySubtle: Color { .primary50 } + + // MARK: - Semantic / Action + static var actionBeigeDefault: Color { .beige300 } + static var actionBeigePressed: Color { .beige400 } + static var actionBeigeStrong: Color { .beige600 } + static var actionBeigeSubtle: Color { .beige50 } + static var actionGrayDefault: Color { .gray700 } + static var actionGrayPressed: Color { .gray900 } + static var actionPrimaryDefault: Color { .primary500 } + static var actionPrimaryDisabled: Color { .primary200 } + static var actionPrimaryPressed: Color { .primary800 } + static var actionPrimarySubtle: Color { .primary100 } + static var actionSecondaryDefault: Color { .secondary50 } + + // MARK: - Semantic / Icon + static var iconGrayDefault: Color { .gray900 } + static var iconGrayInverse: Color { .beige50 } + static var iconGraySubtle: Color { .gray300 } + static var iconPrimaryDefault: Color { .primary500 } - // MARK: - Semantic / Background - static var bgBrand: Color { .beige200 } - static var bgDefault: Color { .init(hex: "FAFAF9") } - static var bgInverse: Color { .neutral800 } - static var bgOverlay: Color { .init(hex: "000000", alpha: 0.4) } - static var bgSubtle: Color { .init(hex: "F5F5F4") } - static var bgTertiary: Color { .neutral50 } - static var borderGray: Color { .init(hex: "CCCCCC") } - static var gray50: Color { .init(hex: "FFFFFF")} - - // MARK: - Semantic / Status - static var statusErrorAlpha: Color { .init(hex: "C92D33", alpha: 0.4) } - static var statusError: Color { .init(hex: "C92D33") } - static var statusWarningAlpha: Color { .init(hex: "FFB400", alpha: 0.4) } - static var statusWarning: Color { .init(hex: "FFB400") } // MARK: - Component - static var bedgeFilledBackgroundDefault: Color { .beige600 } - static var bedgeFilledBackgroundInverse: Color { .primary500 } - static var bedgeFilledTextDefault: Color { .primary500 } - static var bedgeFilledTextInverse: Color { .beige50 } - static var bedgeOutlineBackround: Color { .beige50 } - static var bedgeOutlineBorder: Color { .primary100 } - static var bedgeOutlineText: Color { .primary500 } + static var avatarBackround: Color { .beige600 } + static var badgeCounterBackground: Color { .gray500 } + static var badgeCounterTextActive: Color { .beige50 } + static var badgeCounterTextDefault: Color { .gray300 } + static var badgeFilledBackground: Color { .beige600 } + static var badgeFilledText: Color { .primary500 } + static var badgeOutlineBackground: Color { .beige50 } + static var badgeOutlineBorder: Color { .primary100 } + static var badgeOutlineText: Color { .primary500 } + static var badgePrimaryBackground: Color { .primary500 } + static var badgePrimaryText: Color { .beige50 } + static var buttonIconBackgroundDefault: Color { .primary500 } + static var buttonIconBackgroundDisabled: Color { .primary200 } static var buttonPrimaryBackgroundDefault: Color { .primary500 } static var buttonPrimaryBackgroundDisabled: Color { .primary200 } static var buttonPrimaryBackgroundPressed: Color { .primary800 } static var buttonPrimaryTextDefault: Color { .beige50 } - static var buttonSecondaryBackgroundDefault: Color { .beige300 } - static var buttonSecondaryBackgroundPressed: Color { .beige400 } - static var buttonSecondaryBorderDefault: Color { .beige600 } - static var buttonSecondaryBorderPressed: Color { .secondary500 } - static var buttonSecondaryTextDefault: Color { .neutral600 } - static var buttonSecondaryTextDisabled: Color { .neutral300 } - static var inputBorderActive: Color { .beige700 } - static var inputBorderDefault: Color { .beige600 } - static var inputBorderError: Color { .borderError } - static var inputSurfaceDefault: Color { .beige50 } - static var inputSurfaceDisabled: Color { .beige300 } - static var inputTextActive: Color { .neutral500 } - static var inputTextDefault: Color { .neutral300 } - static var inputTextError: Color { .statusError } - + static var buttonSecondaryBackgroundDefault: Color { .secondary50 } + static var buttonSecondaryBackgroundPressed: Color { .beige600 } + static var buttonSecondaryTextDefault: Color { .primary500 } + static var cardBaseBackgroundBeige: Color { .beige400 } + static var cardBaseBackgroundDefault: Color { .beige50 } + static var cardBaseBorderDefault: Color { .beige600 } + static var cardBaseBorderPrimary: Color { .primary500 } + static var cardBaseBorderSelected: Color { .secondary500 } + static var cardBaseTextBody: Color { .gray400 } + static var cardBaseTextDecription: Color { .gray300 } + static var cardBaseTextInverse: Color { .beige50 } + static var cardBaseTextPrimary: Color { .primary500 } + static var cardBaseTextSecondary: Color { .secondary500 } + static var cardBaseTextTitle: Color { .gray500 } + static var cardGrayBackgroundDefault: Color { .gray700 } + static var cardGrayBackgroundPressed: Color { .gray900 } + static var cardGrayBorderSelected: Color { .secondary500 } + static var cardOpinionBackgroundDefault: Color { .beige300 } + static var cardOpinionBackgroundPressed: Color { .beige400 } + static var cardOpinionBorderDefault: Color { .beige600 } + static var cardOpinionBorderPressed: Color { .secondary500 } + static var checkboxBackgroundDefault: Color { .primary50 } + static var checkboxBackgroundSelected: Color { .primary500 } + static var checkboxBorderDefault: Color { .primary100 } + static var checkboxBorderSelected: Color { .primary700 } + static var chipBackgroundDefault: Color { .primary500 } + static var chipBackgroundSelected: Color { .primary50 } + static var chipBorderDefault: Color { .primary500 } + static var chipTextDefault: Color { .beige50 } + static var chipTextSelected: Color { .primary500 } + static var inputTextareaBackgroundDefault: Color { .beige50 } + static var inputTextareaBackgroundDisabled: Color { .beige600 } + static var inputTextareaBorderError: Color { .init(hex: "C92D33", alpha: 0.4) } + static var inputTextareaTextDefault: Color { .gray300 } + static var inputTextareaTextError: Color { .errorDefault } + static var inputTextareaTextFocus: Color { .gray500 } + static var inputTextfieldBackgroundDefault: Color { .beige50 } + static var inputTextfieldBackgroundDisabled: Color { .beige400 } + static var inputTextfieldBorderDefault: Color { .beige600 } + static var inputTextfieldBorderError: Color { .primary100 } + static var inputTextfieldBorderFocus: Color { .beige700 } + static var inputTextfieldTextDefault: Color { .gray300 } + static var inputTextfieldTextError: Color { .errorDefault } + static var inputTextfieldTextFocus: Color { .gray500 } + static var listItemAgreementBackgroundDefault: Color { .bgDefault } + static var listItemAgreementBorderDefault: Color { .gray50 } + static var listItemAgreementBorderError: Color { .init(hex: "C92D33", alpha: 0.4) } + static var listItemAgreementTextDefualt: Color { .gray800 } + static var listItemAgreementTextError: Color { .errorDefault } + static var navigationTabBorderActive: Color { .primary500 } + static var navigationTabBorderDefault: Color { .gray100 } + static var navigationTabTextActive: Color { .primary500 } + static var navigationTabTextDefault: Color { .gray300 } + static var popupBackground: Color { .beige400 } + static var popupBorder: Color { .primary500 } + static var popupText: Color { .gray800 } + static var toggleThumbDefault: Color { .beige50 } + static var toggleTrackOff: Color { .gray100 } + static var toggleTrackOn: Color { .primary500 } } diff --git a/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Component+.swift b/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Component+.swift index 663eae5..2b35bf6 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Component+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Component+.swift @@ -1,10 +1,45 @@ // AUTO-GENERATED by Tools/TokenGenerator.swift — DO NOT EDIT -// Source: Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json +// Source: SWYP-Find/design-tokens repo (primitive.json / semantic.json / component.json) import CoreGraphics public extension CGFloat { // MARK: - Component - static let buttonRadius: CGFloat = .`default` + static let buttonIconSize: CGFloat = 36 + static let buttonPrimaryLargeHeight: CGFloat = 52 + static let buttonPrimaryLargeWidth: CGFloat = 343 + static let buttonPrimaryMediumHeight: CGFloat = 42 + static let buttonPrimaryMediumWidth: CGFloat = 343 + static let buttonPrimarySmallHeight: CGFloat = 42 + static let buttonPrimarySmallWidth: CGFloat = 100 + static let cardAvatarLg: CGFloat = 68 + static let cardAvatarMd: CGFloat = 40 + static let cardAvatarSm: CGFloat = 36 + static let checkboxHeight: CGFloat = 24 + static let checkboxWidth: CGFloat = 24 + static let inputTextfieldHeight: CGFloat = 44 + static let inputTextfieldWidth: CGFloat = 343 + static let paddingAppbarHorizontal: CGFloat = 16 + static let paddingAppbarVertical: CGFloat = 12 + static let paddingBadgeHorizontal: CGFloat = 6 + static let paddingBadgeVertical: CGFloat = 2 + static let paddingButtonPrimaryLargeHorizontal: CGFloat = 12 + static let paddingButtonPrimaryLargeVertical: CGFloat = 12 + static let paddingButtonPrimaryMediumHorizontal: CGFloat = 12 + static let paddingButtonPrimaryMediumVertical: CGFloat = 12 + static let paddingButtonPrimarySmallHorizontal: CGFloat = 12 + static let paddingButtonPrimarySmallVertical: CGFloat = 12 + static let paddingCardBase: CGFloat = 16 + static let paddingChipHorizontal: CGFloat = 12 + static let paddingChipVertical: CGFloat = 6 + static let paddingInputHorizontal: CGFloat = 8 + static let paddingNavigationTabHorizontal: CGFloat = 12 + static let paddingNavigationTabVertical: CGFloat = 12 + static let popupWidth: CGFloat = 393 + static let thumbnailHeight: CGFloat = 140 + static let thumbnailWidth: CGFloat = 196 + static let toggleHeight: CGFloat = 18 + static let toggleThumbSize: CGFloat = 14 + static let toggleWidth: CGFloat = 32 } diff --git a/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Radius+.swift b/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Radius+.swift index ead9896..94b4608 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Radius+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Radius+.swift @@ -1,12 +1,11 @@ // AUTO-GENERATED by Tools/TokenGenerator.swift — DO NOT EDIT -// Source: Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json +// Source: SWYP-Find/design-tokens repo (primitive.json / semantic.json / component.json) import CoreGraphics public extension CGFloat { // MARK: - Radius - static let none: CGFloat = 0 - static let `default`: CGFloat = 2 - static let full: CGFloat = 999 + static let radiusDefault: CGFloat = 2 + static let radiusMax: CGFloat = 999 } diff --git a/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Spacing+.swift b/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Spacing+.swift index 03bff40..ae90fe1 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Spacing+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/CGFloat/CGFloat+Spacing+.swift @@ -1,16 +1,19 @@ // AUTO-GENERATED by Tools/TokenGenerator.swift — DO NOT EDIT -// Source: Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json +// Source: SWYP-Find/design-tokens repo (primitive.json / semantic.json / component.json) import CoreGraphics public extension CGFloat { - // MARK: - Spacing + // MARK: - Primitive Spacing static let s0: CGFloat = 0 static let s2: CGFloat = 2 static let s4: CGFloat = 4 + static let s6: CGFloat = 6 static let s8: CGFloat = 8 + static let s12: CGFloat = 12 static let s16: CGFloat = 16 + static let s20: CGFloat = 20 static let s24: CGFloat = 24 static let s32: CGFloat = 32 static let s40: CGFloat = 40 @@ -18,4 +21,49 @@ public extension CGFloat { static let s64: CGFloat = 64 static let s80: CGFloat = 80 static let s96: CGFloat = 96 + + // MARK: - Primitive Sizing + static let iconLg: CGFloat = 24 + static let iconMd: CGFloat = 20 + static let iconSm: CGFloat = 16 + static let iconXs: CGFloat = 12 + static let avatarLg: CGFloat = 68 + static let avatarMd: CGFloat = 40 + static let avatarSm: CGFloat = 36 + static let controlMd: CGFloat = 52 + static let controlSm: CGFloat = 28 + + // MARK: - Semantic Gap + static let gap0: CGFloat = 0 + static let gap2: CGFloat = 2 + static let gap4: CGFloat = 4 + static let gap6: CGFloat = 6 + static let gap8: CGFloat = 8 + static let gap12: CGFloat = 12 + static let gap16: CGFloat = 16 + static let gap20: CGFloat = 20 + static let gap24: CGFloat = 24 + static let gap32: CGFloat = 32 + static let gap40: CGFloat = 40 + + // MARK: - Semantic Padding + static let paddingComponent12: CGFloat = 12 + static let paddingComponent16: CGFloat = 16 + static let paddingComponent2: CGFloat = 2 + static let paddingComponent4: CGFloat = 4 + static let paddingComponent6: CGFloat = 6 + static let paddingComponent8: CGFloat = 8 + static let paddingContainer12: CGFloat = 12 + static let paddingContainer16: CGFloat = 16 + static let paddingContainer20: CGFloat = 20 + static let paddingContainer24: CGFloat = 24 + static let paddingContainer32: CGFloat = 32 + static let paddingContainer40: CGFloat = 40 + static let paddingContainer8: CGFloat = 8 + static let paddingScreenHorizontal: CGFloat = 16 + + // MARK: - Semantic Border Width + static let borderWidthRegular: CGFloat = 1 + static let borderWidthMedium: CGFloat = 1.5 + static let borderWidthLarge: CGFloat = 4 } diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 210ecb2..c19d69a 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -25,17 +25,27 @@ public enum ImageAsset: String { case onboarding3 case onboarding4 - // MARK: - GNB 탭 아이콘 + // MARK: - GNB 탭 아이콘 (비선택 / 선택) case tabHome + case tabHomeActive case tabExplore + case tabExploreActive case tabQuickBattle + case tabQuickBattleActive case tabMyPage - - - //MARK: - Home 탭 아이콘 + case tabMyPageActive + + // MARK: - Home 탭 아이콘 + case appLogo case bell + // MARK: - 철학자 아바타 + + case avatarPlato + case avatarSartre + case avatarSunja + case none } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButtonConfig.swift b/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButtonConfig.swift index 0c6fe5d..eea39b4 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButtonConfig.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Button/CustomButtonConfig.swift @@ -14,13 +14,19 @@ public class CustomButtonConfig: PickeCustomButtonConfig { } /// CTA primary 팩토리. variant + size 조합을 `PickeCustomButtonConfig`로 변환한다. - public static func primary(_ size: CTAButtonSize) -> PickeCustomButtonConfig { + /// - Parameters: + /// - size: `CTAButtonSize` (기본 height 사용) + /// - height: 호출처에서 size.height 를 override 하고 싶을 때 명시 (e.g. .pen 디자인의 52pt) + public static func primary( + _ size: CTAButtonSize, + height: CGFloat? = nil + ) -> PickeCustomButtonConfig { let variant: CTAButtonVariant = .primary return PickeCustomButtonConfig( - cornerRadius: .default, + cornerRadius: .radiusDefault, enableFontColor: variant.foregroundColor(isEnabled: true), enableBackgroundColor: variant.backgroundColor(isEnabled: true), - frameHeight: size.height, + frameHeight: height ?? size.height, disableFontColor: variant.foregroundColor(isEnabled: false), disableBackgroundColor: variant.backgroundColor(isEnabled: false) ) diff --git a/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift b/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift new file mode 100644 index 0000000..382d463 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift @@ -0,0 +1,76 @@ +// +// PickeNavigationBar.swift +// DesignSystem +// +// Picke 공통 네비게이션 바 — 좌측 back / 가운데 옵션 / 우측 ViewBuilder. +// + +import SwiftUI + +/// 화면 상단에 공통으로 올라가는 네비게이션 바. +/// - `onBack` 이 nil 이면 좌측 영역은 24×24 placeholder 만 남는다. +/// - `centerIcon` 이 nil 이면 가운데는 빈 공간. +/// - 우측은 호출처에서 자유롭게 ViewBuilder 로 주입. +/// +/// 색상은 호출처에서 `.foregroundStyle(.beige50)` 같은 modifier 로 위임. +public struct PickeNavigationBar: View { + private let onBack: (() -> Void)? + private let centerIcon: Image? + private let trailing: () -> Trailing + + public init( + onBack: (() -> Void)? = nil, + centerIcon: Image? = nil, + @ViewBuilder trailing: @escaping () -> Trailing + ) { + self.onBack = onBack + self.centerIcon = centerIcon + self.trailing = trailing + } + + public var body: some View { + HStack { + leadingArea + Spacer() + centerArea + Spacer() + trailing() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + @ViewBuilder + private var leadingArea: some View { + if let onBack { + Button { onBack() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } else { + Color.clear.frame(width: 24, height: 24) + } + } + + @ViewBuilder + private var centerArea: some View { + if let centerIcon { + centerIcon + .font(.system(size: 16, weight: .semibold)) + .frame(width: 24, height: 24) + } else { + EmptyView() + } + } +} + +public extension PickeNavigationBar where Trailing == EmptyView { + init( + onBack: (() -> Void)? = nil, + centerIcon: Image? = nil + ) { + self.init(onBack: onBack, centerIcon: centerIcon) { EmptyView() } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift b/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift new file mode 100644 index 0000000..367d116 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift @@ -0,0 +1,23 @@ +// +// ShareSheet.swift +// DesignSystem +// +// 애플 기본 공유 시트 (UIActivityViewController) 를 SwiftUI `.sheet` 로 띄우기 위한 wrapper. +// + +import SwiftUI +import UIKit + +public struct ShareSheet: UIViewControllerRepresentable { + private let items: [Any] + + public init(items: [Any]) { + self.items = items + } + + public func makeUIViewController(context _: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + public func updateUIViewController(_: UIActivityViewController, context _: Context) {} +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonView.swift b/Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonView.swift new file mode 100644 index 0000000..55cdc86 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonView.swift @@ -0,0 +1,46 @@ +// +// SkeletonView.swift +// DesignSystem +// +// KFImage 등 비동기 이미지 로딩 placeholder 용 공용 skeleton. +// shimmer 그라데이션을 좌→우로 반복해 로딩 중임을 시각화한다. +// + +import SwiftUI + +public struct SkeletonView: View { + private let cornerRadius: CGFloat + + public init(cornerRadius: CGFloat = 2) { + self.cornerRadius = cornerRadius + } + + @State private var phase: CGFloat = -1 + + private let baseColor = Color(red: 239 / 255, green: 234 / 255, blue: 224 / 255) // beige600 #EFEAE0 + private let shimmerColor = Color(red: 254 / 255, green: 254 / 255, blue: 253 / 255) // beige50 #FEFEFD + + public var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(baseColor) + .overlay { + LinearGradient( + stops: [ + .init(color: baseColor.opacity(0), location: 0), + .init(color: shimmerColor.opacity(0.6), location: 0.5), + .init(color: baseColor.opacity(0), location: 1), + ], + startPoint: UnitPoint(x: phase, y: 0.5), + endPoint: UnitPoint(x: phase + 1, y: 0.5) + ) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .onAppear { + withAnimation( + .linear(duration: 1.2).repeatForever(autoreverses: false) + ) { + phase = 2 + } + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastType.swift b/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastType.swift index 490c8b3..f61f57c 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastType.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Toast/ToastType.swift @@ -28,15 +28,15 @@ public enum ToastType: Equatable { public var backgroundColor: Color { switch self { case .success: - return .neutral50 + return .gray50 case .error: - return .neutral50 + return .gray50 case .warning: - return .neutral50 + return .gray50 case .info: - return .neutral50 + return .gray50 case .loading: - return .neutral50 + return .gray50 } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Token/ComponentToken.swift b/Projects/Shared/DesignSystem/Sources/UI/Token/ComponentToken.swift index 6b2e567..35007f1 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Token/ComponentToken.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Token/ComponentToken.swift @@ -1,31 +1,49 @@ // AUTO-GENERATED by Tools/TokenGenerator.swift — DO NOT EDIT -// Source: Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json +// Source: SWYP-Find/design-tokens repo (primitive.json / semantic.json / component.json) import SwiftUI public enum ComponentToken { - public enum Bedge { - public enum Filled { - public enum Background { - public static var `default`: Color { .bedgeFilledBackgroundDefault } - public static var inverse: Color { .bedgeFilledBackgroundInverse } - } + public enum Avatar { + public static var backround: Color { .avatarBackround } + } + + public enum Badge { + public enum Counter { + public static var background: Color { .badgeCounterBackground } public enum Text { - public static var `default`: Color { .bedgeFilledTextDefault } - public static var inverse: Color { .bedgeFilledTextInverse } + public static var active: Color { .badgeCounterTextActive } + public static var `default`: Color { .badgeCounterTextDefault } } } + public enum Filled { + public static var background: Color { .badgeFilledBackground } + public static var text: Color { .badgeFilledText } + } + public enum Outline { - public static var backround: Color { .bedgeOutlineBackround } - public static var border: Color { .bedgeOutlineBorder } - public static var text: Color { .bedgeOutlineText } + public static var background: Color { .badgeOutlineBackground } + public static var border: Color { .badgeOutlineBorder } + public static var text: Color { .badgeOutlineText } + } + + public enum Primary { + public static var background: Color { .badgePrimaryBackground } + public static var text: Color { .badgePrimaryText } } } public enum Button { - public static var radius: CGFloat { .buttonRadius } + public enum Icon { + public static var size: CGFloat { .buttonIconSize } + + public enum Background { + public static var `default`: Color { .buttonIconBackgroundDefault } + public static var disabled: Color { .buttonIconBackgroundDisabled } + } + } public enum Primary { public enum Background { @@ -34,6 +52,21 @@ public enum ComponentToken { public static var pressed: Color { .buttonPrimaryBackgroundPressed } } + public enum Large { + public static var height: CGFloat { .buttonPrimaryLargeHeight } + public static var width: CGFloat { .buttonPrimaryLargeWidth } + } + + public enum Medium { + public static var height: CGFloat { .buttonPrimaryMediumHeight } + public static var width: CGFloat { .buttonPrimaryMediumWidth } + } + + public enum Small { + public static var height: CGFloat { .buttonPrimarySmallHeight } + public static var width: CGFloat { .buttonPrimarySmallWidth } + } + public enum Text { public static var `default`: Color { .buttonPrimaryTextDefault } } @@ -45,34 +78,244 @@ public enum ComponentToken { public static var pressed: Color { .buttonSecondaryBackgroundPressed } } + public enum Text { + public static var `default`: Color { .buttonSecondaryTextDefault } + } + } + } + + public enum Card { + public enum Avatar { + public static var lg: CGFloat { .cardAvatarLg } + public static var md: CGFloat { .cardAvatarMd } + public static var sm: CGFloat { .cardAvatarSm } + } + + public enum Base { + public enum Background { + public static var beige: Color { .cardBaseBackgroundBeige } + public static var `default`: Color { .cardBaseBackgroundDefault } + } + public enum Border { - public static var `default`: Color { .buttonSecondaryBorderDefault } - public static var pressed: Color { .buttonSecondaryBorderPressed } + public static var `default`: Color { .cardBaseBorderDefault } + public static var primary: Color { .cardBaseBorderPrimary } + public static var selected: Color { .cardBaseBorderSelected } } public enum Text { - public static var `default`: Color { .buttonSecondaryTextDefault } - public static var disabled: Color { .buttonSecondaryTextDisabled } + public static var body: Color { .cardBaseTextBody } + public static var decription: Color { .cardBaseTextDecription } + public static var inverse: Color { .cardBaseTextInverse } + public static var primary: Color { .cardBaseTextPrimary } + public static var secondary: Color { .cardBaseTextSecondary } + public static var title: Color { .cardBaseTextTitle } + } + } + + public enum Gray { + public enum Background { + public static var `default`: Color { .cardGrayBackgroundDefault } + public static var pressed: Color { .cardGrayBackgroundPressed } + } + + public enum Border { + public static var selected: Color { .cardGrayBorderSelected } + } + } + + public enum Opinion { + public enum Background { + public static var `default`: Color { .cardOpinionBackgroundDefault } + public static var pressed: Color { .cardOpinionBackgroundPressed } + } + + public enum Border { + public static var `default`: Color { .cardOpinionBorderDefault } + public static var pressed: Color { .cardOpinionBorderPressed } } } } - public enum Input { + public enum Checkbox { + public static var height: CGFloat { .checkboxHeight } + public static var width: CGFloat { .checkboxWidth } + + public enum Background { + public static var `default`: Color { .checkboxBackgroundDefault } + public static var selected: Color { .checkboxBackgroundSelected } + } + public enum Border { - public static var active: Color { .inputBorderActive } - public static var `default`: Color { .inputBorderDefault } - public static var error: Color { .inputBorderError } + public static var `default`: Color { .checkboxBorderDefault } + public static var selected: Color { .checkboxBorderSelected } + } + } + + public enum Chip { + public enum Background { + public static var `default`: Color { .chipBackgroundDefault } + public static var selected: Color { .chipBackgroundSelected } } - public enum Surface { - public static var `default`: Color { .inputSurfaceDefault } - public static var disabled: Color { .inputSurfaceDisabled } + public enum Border { + public static var `default`: Color { .chipBorderDefault } } public enum Text { - public static var active: Color { .inputTextActive } - public static var `default`: Color { .inputTextDefault } - public static var error: Color { .inputTextError } + public static var `default`: Color { .chipTextDefault } + public static var selected: Color { .chipTextSelected } + } + } + + public enum Input { + public enum Textarea { + public enum Background { + public static var `default`: Color { .inputTextareaBackgroundDefault } + public static var disabled: Color { .inputTextareaBackgroundDisabled } + } + + public enum Border { + public static var error: Color { .inputTextareaBorderError } + } + + public enum Text { + public static var `default`: Color { .inputTextareaTextDefault } + public static var error: Color { .inputTextareaTextError } + public static var focus: Color { .inputTextareaTextFocus } + } + } + + public enum Textfield { + public static var height: CGFloat { .inputTextfieldHeight } + public static var width: CGFloat { .inputTextfieldWidth } + + public enum Background { + public static var `default`: Color { .inputTextfieldBackgroundDefault } + public static var disabled: Color { .inputTextfieldBackgroundDisabled } + } + + public enum Border { + public static var `default`: Color { .inputTextfieldBorderDefault } + public static var error: Color { .inputTextfieldBorderError } + public static var focus: Color { .inputTextfieldBorderFocus } + } + + public enum Text { + public static var `default`: Color { .inputTextfieldTextDefault } + public static var error: Color { .inputTextfieldTextError } + public static var focus: Color { .inputTextfieldTextFocus } + } + } + } + + public enum ListItem { + public enum Agreement { + public enum Background { + public static var `default`: Color { .listItemAgreementBackgroundDefault } + } + + public enum Border { + public static var `default`: Color { .listItemAgreementBorderDefault } + public static var error: Color { .listItemAgreementBorderError } + } + + public enum Text { + public static var defualt: Color { .listItemAgreementTextDefualt } + public static var error: Color { .listItemAgreementTextError } + } + } + } + + public enum Navigation { + public enum Tab { + public enum Border { + public static var active: Color { .navigationTabBorderActive } + public static var `default`: Color { .navigationTabBorderDefault } + } + + public enum Text { + public static var active: Color { .navigationTabTextActive } + public static var `default`: Color { .navigationTabTextDefault } + } + } + } + + public enum Padding { + public enum Appbar { + public static var horizontal: CGFloat { .paddingAppbarHorizontal } + public static var vertical: CGFloat { .paddingAppbarVertical } + } + + public enum Badge { + public static var horizontal: CGFloat { .paddingBadgeHorizontal } + public static var vertical: CGFloat { .paddingBadgeVertical } + } + + public enum Button { + public enum Primary { + public enum Large { + public static var horizontal: CGFloat { .paddingButtonPrimaryLargeHorizontal } + public static var vertical: CGFloat { .paddingButtonPrimaryLargeVertical } + } + + public enum Medium { + public static var horizontal: CGFloat { .paddingButtonPrimaryMediumHorizontal } + public static var vertical: CGFloat { .paddingButtonPrimaryMediumVertical } + } + + public enum Small { + public static var horizontal: CGFloat { .paddingButtonPrimarySmallHorizontal } + public static var vertical: CGFloat { .paddingButtonPrimarySmallVertical } + } + } + } + + public enum Card { + public static var base: CGFloat { .paddingCardBase } + } + + public enum Chip { + public static var horizontal: CGFloat { .paddingChipHorizontal } + public static var vertical: CGFloat { .paddingChipVertical } + } + + public enum Input { + public static var horizontal: CGFloat { .paddingInputHorizontal } + } + + public enum Navigation { + public enum Tab { + public static var horizontal: CGFloat { .paddingNavigationTabHorizontal } + public static var vertical: CGFloat { .paddingNavigationTabVertical } + } + } + } + + public enum Popup { + public static var background: Color { .popupBackground } + public static var border: Color { .popupBorder } + public static var text: Color { .popupText } + public static var width: CGFloat { .popupWidth } + } + + public enum Thumbnail { + public static var height: CGFloat { .thumbnailHeight } + public static var width: CGFloat { .thumbnailWidth } + } + + public enum Toggle { + public static var height: CGFloat { .toggleHeight } + public static var width: CGFloat { .toggleWidth } + + public enum Thumb { + public static var `default`: Color { .toggleThumbDefault } + public static var size: CGFloat { .toggleThumbSize } + } + + public enum Track { + public static var off: Color { .toggleTrackOff } + public static var on: Color { .toggleTrackOn } } } } diff --git a/Tools/TokenGenerator.swift b/Tools/TokenGenerator.swift index a73f300..f3a4dce 100644 --- a/Tools/TokenGenerator.swift +++ b/Tools/TokenGenerator.swift @@ -1,76 +1,146 @@ #!/usr/bin/env swift // // TokenGenerator.swift -// Reads Mode 1.tokens.json and emits Swift token files. -// Run from repo root: swift Tools/TokenGenerator.swift +// Reads Tokens Studio JSON (primitive/semantic/component) from the SWYP-Find/design-tokens +// repo and emits Swift token files. Source resolution order: +// 1. $TOKENS_SOURCE_DIR (CI / explicit override) +// 2. ../design-tokens (local layout: sibling checkout) +// Run from Picke-iOS repo root: swift Tools/TokenGenerator.swift // import Foundation -// MARK: - Paths +// MARK: - Source resolution +let env = ProcessInfo.processInfo.environment let cwd = FileManager.default.currentDirectoryPath -let jsonURL = URL(fileURLWithPath: "\(cwd)/Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json") + +func resolveSourceDir() -> String { + if let override = env["TOKENS_SOURCE_DIR"], !override.isEmpty { + return override + } + let sibling = URL(fileURLWithPath: cwd).deletingLastPathComponent() + .appendingPathComponent("design-tokens").path + return sibling +} + +let sourceDir = resolveSourceDir() +let primitiveURL = URL(fileURLWithPath: "\(sourceDir)/primitive.json") +let semanticURL = URL(fileURLWithPath: "\(sourceDir)/semantic.json") +let componentURL = URL(fileURLWithPath: "\(sourceDir)/component.json") + +for url in [primitiveURL, semanticURL, componentURL] { + guard FileManager.default.fileExists(atPath: url.path) else { + fputs(""" + [token-gen] cannot find \(url.lastPathComponent) at: \(url.path) + Set TOKENS_SOURCE_DIR to your design-tokens checkout, e.g. + TOKENS_SOURCE_DIR=../design-tokens swift Tools/TokenGenerator.swift + Or clone SWYP-Find/design-tokens as a sibling of this repo. + + """, stderr) + exit(1) + } +} + +// MARK: - Output paths + let sourcesDir = "\(cwd)/Projects/Shared/DesignSystem/Sources" let colorOut = "\(sourcesDir)/Color/ShapeStyle+.swift" let cgfloatDir = "\(sourcesDir)/Extension/CGFloat" let radiusOut = "\(cgfloatDir)/CGFloat+Radius+.swift" let spacingOut = "\(cgfloatDir)/CGFloat+Spacing+.swift" -let componentOut = "\(sourcesDir)/UI/Token/ComponentToken.swift" // legacy nested file (deleted at end) let componentNumberOut = "\(cgfloatDir)/CGFloat+Component+.swift" +let componentTokenOut = "\(sourcesDir)/UI/Token/ComponentToken.swift" + try? FileManager.default.createDirectory(atPath: "\(sourcesDir)/UI/Token", withIntermediateDirectories: true) try? FileManager.default.createDirectory(atPath: cgfloatDir, withIntermediateDirectories: true) -let data: Data -do { - data = try Data(contentsOf: jsonURL) -} catch { - fputs("[token-gen] cannot read JSON: \(jsonURL.path)\n", stderr) - exit(1) -} +// MARK: - Loading -guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - fputs("[token-gen] invalid JSON root\n", stderr); exit(1) +func loadJSON(_ url: URL) -> [String: Any] { + guard let data = try? Data(contentsOf: url), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + fputs("[token-gen] cannot read \(url.path)\n", stderr) + exit(1) + } + return obj } -// MARK: - Helpers +let primitive = loadJSON(primitiveURL) +let semantic = loadJSON(semanticURL) +let component = loadJSON(componentURL) + +// MARK: - Registry & reference resolution -func valueOf(_ any: Any) -> Any? { - (any as? [String: Any])?["$value"] +typealias TokenNode = [String: Any] +typealias Registry = [String: TokenNode] + +/// Flattens a token tree by dotted path. Leaves are `{$type, $value, ...}` dicts. +func flatten(_ tree: [String: Any], path: [String], into registry: inout Registry) { + for (key, value) in tree { + guard let dict = value as? [String: Any] else { continue } + let newPath = path + [key] + if dict["$type"] != nil, dict["$value"] != nil { + registry[newPath.joined(separator: ".")] = dict + } else { + flatten(dict, path: newPath, into: ®istry) + } + } } -func hexAlpha(_ value: Any) -> (hex: String, alpha: Double)? { - guard let d = value as? [String: Any], let hex = d["hex"] as? String else { return nil } - let raw = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex - let a = (d["alpha"] as? Double) ?? 1.0 - return (raw.uppercased(), a) +var registry: Registry = [:] +flatten(primitive, path: [], into: ®istry) +flatten(semantic, path: [], into: ®istry) +flatten(component, path: [], into: ®istry) + +/// `"{primary.500}"` → `"primary.500"`. Nil for non-references. +func referencePath(_ s: String) -> String? { + guard s.hasPrefix("{"), s.hasSuffix("}") else { return nil } + return String(s.dropFirst().dropLast()) } -func aliasToSwiftName(_ alias: String) -> String? { - var s = alias - if s.hasPrefix("{"), s.hasSuffix("}") { s = String(s.dropFirst().dropLast()) } - let p = s.split(separator: ".").map(String.init) - if p.count >= 4, p[0] == "Colors", p[1] == "brand" { - if p.count == 5, p[3] == "Alpha" { return "\(p[2])Alpha\(p[4])" } - return "\(p[2])\(p[3])" +/// Recursively resolves a `$value`, following `{...}` references until a literal is reached. +/// Returns nil only on cycle. +func resolveValue(_ value: Any, visited: Set = []) -> Any? { + if let s = value as? String, let ref = referencePath(s) { + if visited.contains(ref) { return nil } + guard let node = registry[ref], let nested = node["$value"] else { return s } + return resolveValue(nested, visited: visited.union([ref])) } - if p.count >= 5, p[0] == "Colors", p[1] == "semantic", p[2] == "status" { - let bucket = p[3].prefix(1).uppercased() + p[3].dropFirst() - let leaf = p[4] - return leaf == "Alpha" ? "status\(bucket)Alpha" : "status\(bucket)" + return value +} + +/// Resolves to Double for number/spacing/sizing/borderRadius/borderWidth/fontSizes. +func resolveNumber(_ value: Any) -> Double? { + let resolved = resolveValue(value) ?? value + if let n = resolved as? Double { return n } + if let n = resolved as? Int { return Double(n) } + if let s = resolved as? String, let n = Double(s) { return n } + return nil +} + +/// Resolves to a hex color `(uppercase, no '#')` plus alpha. Handles `#RRGGBB` and `rgba(r,g,b,a)`. +func resolveHex(_ value: Any) -> (hex: String, alpha: Double)? { + let resolved = resolveValue(value) ?? value + guard let s = resolved as? String else { return nil } + if s.hasPrefix("#") { + return (String(s.dropFirst()).uppercased(), 1.0) } - if p.count == 4, p[0] == "Colors", p[1] == "semantic" { - let key = p[2] - let prefix = (key == "background") ? "bg" : key - return "\(prefix)\(capitalizeFirst(p[3]))" + if s.hasPrefix("rgba(") { + let inner = s.replacingOccurrences(of: "rgba(", with: "").replacingOccurrences(of: ")", with: "") + let parts = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + if parts.count == 4, + let r = Double(parts[0]), let g = Double(parts[1]), let b = Double(parts[2]), let a = Double(parts[3]) + { + let hex = String(format: "%02X%02X%02X", Int(r), Int(g), Int(b)) + return (hex, a) + } } return nil } -// 'Primary200' → 'primary200' / 'BorderError' → 'borderError' -func lowerFirst(_ s: String) -> String { - s.prefix(1).lowercased() + s.dropFirst() -} +// MARK: - Naming helpers let swiftKeywords: Set = [ "default", "case", "enum", "class", "struct", "var", "let", "func", "init", @@ -84,125 +154,21 @@ func swiftKey(_ s: String) -> String { swiftKeywords.contains(s) ? "`\(s)`" : s } -// hex→Swift 변수명 인덱스 (alpha=1 brand/semantic만). Component이 inline hex로 export 됐을 때 fallback 매칭용. -var hexIndex: [String: String] = [:] -var knownColorNames: Set = [] - -func resolveComponentColor(_ node: [String: Any]) -> String? { - if let str = node["$value"] as? String, let name = aliasToSwiftName(str) { - return ".\(name)" - } - if let v = node["$value"] as? [String: Any], let hex = v["hex"] as? String { - let raw = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex - let normalized = raw.uppercased() - let alpha = (v["alpha"] as? Double) ?? 1.0 - // 1) aliasData.targetVariableName — 우리 토큰셋에 존재할 때만 사용 - if let exts = node["$extensions"] as? [String: Any], - let alias = exts["com.figma.aliasData"] as? [String: Any], - let target = alias["targetVariableName"] as? String, !target.isEmpty - { - let camel = lowerFirst(target) - if knownColorNames.contains(camel) { return ".\(camel)" } - } - // 2) hex 매칭 — 같은 hex의 brand/semantic 변수가 있으면 그쪽으로 묶기 - if alpha >= 1.0, let matched = hexIndex[normalized] { - return ".\(matched)" - } - // 3) fallback: inline hex - return colorBody(hex: normalized, alpha: alpha) - } - return nil -} - -func resolveComponentNumber(_ node: [String: Any]) -> String? { - if let str = node["$value"] as? String { - var s = str - if s.hasPrefix("{"), s.hasSuffix("}") { s = String(s.dropFirst().dropLast()) } - let p = s.split(separator: ".").map(String.init) - if p.count == 2, p[0] == "Radius" { return ".\(swiftKey(p[1]))" } - } - if let n = node["$value"] as? Double { return formatNumber(n) } - return nil -} - -// Component subtree 를 flat path 로 풀어 ShapeStyle / CGFloat 확장에 직접 추가한다. -// Component.button.primary.background.default → buttonPrimaryBackgroundDefault -// Component.button.radius → buttonRadius -func walkComponentFlat( - _ node: [String: Any], - pathPrefix: [String], - colorLines: inout [String], - numberLines: inout [String] -) { - let keys = node.keys.sorted() - let leafKeys = keys.filter { (node[$0] as? [String: Any])?["$type"] != nil } - let groupKeys = keys.filter { (node[$0] as? [String: Any])?["$type"] == nil } - for key in leafKeys { - guard let child = node[key] as? [String: Any], let type = child["$type"] as? String else { continue } - let propName = flatPropertyName(pathPrefix + [key]) - switch type { - case "color": - if let expr = resolveComponentColor(child) { - colorLines.append(" static var \(propName): Color { \(expr) }") - } - case "number": - if let expr = resolveComponentNumber(child) { - numberLines.append(" static let \(propName): CGFloat = \(expr)") - } - default: continue - } - } - for key in groupKeys { - guard let child = node[key] as? [String: Any] else { continue } - walkComponentFlat(child, pathPrefix: pathPrefix + [key], colorLines: &colorLines, numberLines: &numberLines) - } +func capitalizeFirst(_ s: String) -> String { + s.prefix(1).uppercased() + s.dropFirst() } -// Component subtree 의 nested ComponentToken enum. 값은 flat 정의를 forwarding 하므로 -// source of truth 는 항상 ShapeStyle / CGFloat 확장 한 곳. -// public static var `default`: Color { .buttonPrimaryBackgroundDefault } -func walkComponentNested( - _ node: [String: Any], - pathPrefix: [String], - indent: String, - out: inout [String] -) { - let keys = node.keys.sorted() - let leafKeys = keys.filter { (node[$0] as? [String: Any])?["$type"] != nil } - let groupKeys = keys.filter { (node[$0] as? [String: Any])?["$type"] == nil } - for key in leafKeys { - guard let child = node[key] as? [String: Any], let type = child["$type"] as? String else { continue } - let flatName = flatPropertyName(pathPrefix + [key]) - switch type { - case "color": - out.append("\(indent)public static var \(swiftKey(key)): Color { .\(flatName) }") - case "number": - out.append("\(indent)public static var \(swiftKey(key)): CGFloat { .\(flatName) }") - default: continue - } - } - for (i, key) in groupKeys.enumerated() { - guard let child = node[key] as? [String: Any] else { continue } - if i == 0, !leafKeys.isEmpty { out.append("") } - if i > 0 { out.append("") } - out.append("\(indent)public enum \(capitalizeFirst(key)) {") - walkComponentNested(child, pathPrefix: pathPrefix + [key], indent: indent + " ", out: &out) - out.append("\(indent)}") - } +func lowerFirst(_ s: String) -> String { + s.prefix(1).lowercased() + s.dropFirst() } -// ["button", "primary", "background", "default"] → "buttonPrimaryBackgroundDefault" -func flatPropertyName(_ segs: [String]) -> String { +func camelCase(_ segs: [String]) -> String { guard let first = segs.first else { return "" } - let head = first.prefix(1).lowercased() + first.dropFirst() + let head = lowerFirst(first) let tail = segs.dropFirst().map(capitalizeFirst).joined() return swiftKey(head + tail) } -func capitalizeFirst(_ s: String) -> String { - s.prefix(1).uppercased() + s.dropFirst() -} - func formatNumber(_ d: Double) -> String { if d == d.rounded() { return "\(Int(d))" } let rounded = (d * 100).rounded() / 100 @@ -220,150 +186,275 @@ func writeFile(_ path: String, _ contents: String) throws { let header = """ // AUTO-GENERATED by Tools/TokenGenerator.swift — DO NOT EDIT -// Source: Projects/Shared/DesignSystem/Resources/Mode 1.tokens.json +// Source: SWYP-Find/design-tokens repo (primitive.json / semantic.json / component.json) """ -// MARK: - Colors +// MARK: - Color emission -let colors = json["Colors"] as! [String: Any] -let brand = colors["brand"] as! [String: Any] -let semantic = colors["semantic"] as! [String: Any] -let scales = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] -let brandGroups = ["primary", "secondary", "beige", "neutral"] +var hexToColorName: [String: String] = [:] + +func emitColor(name: String, value: Any, into lines: inout [String]) { + guard let parts = resolveHex(value) else { return } + if parts.alpha >= 1.0, let existing = hexToColorName[parts.hex] { + lines.append(" static var \(name): Color { .\(existing) }") + } else { + lines.append(" static var \(name): Color { \(colorBody(hex: parts.hex, alpha: parts.alpha)) }") + if parts.alpha >= 1.0 { hexToColorName[parts.hex] = name } + } +} -var lines: [String] = [header, "", "import SwiftUI", "", "public extension ShapeStyle where Self == Color {", ""] +var colorLines: [String] = [header, "", "import SwiftUI", "", "public extension ShapeStyle where Self == Color {", ""] -// brand -for group in brandGroups { - guard let g = brand[group] as? [String: Any] else { continue } - lines.append(" // MARK: - Brand / \(capitalizeFirst(group))") +// Primitive color scales: primary / secondary / beige / gray (50..900). +let primitiveColorGroups = ["primary", "secondary", "beige", "gray"] +let scales = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] +for group in primitiveColorGroups { + guard let g = primitive[group] as? [String: Any] else { continue } + colorLines.append(" // MARK: - Primitive / \(capitalizeFirst(group))") for s in scales { - if let node = g[s] as? [String: Any], let v = valueOf(node), let h = hexAlpha(v) { - let name = "\(group)\(s)" - lines.append(" static var \(name): Color { \(colorBody(hex: h.hex, alpha: h.alpha)) }") - knownColorNames.insert(name) - if h.alpha >= 1.0 { hexIndex[h.hex] = name } - } + guard let node = g[s] as? [String: Any], let v = node["$value"] else { continue } + emitColor(name: "\(group)\(s)", value: v, into: &colorLines) } - if let alpha = g["Alpha"] as? [String: Any] { - for (k, v) in alpha.sorted(by: { $0.key < $1.key }) { - if let node = v as? [String: Any], let val = valueOf(node), let h = hexAlpha(val) { - let name = "\(group)Alpha\(k)" - lines.append(" static var \(name): Color { \(colorBody(hex: h.hex, alpha: h.alpha)) }") - knownColorNames.insert(name) + colorLines.append("") +} + +// Backward-compat aliases: `neutral{N}` → `gray{N}`. Earlier call sites used `neutral*` +// before the upstream rename to `gray*`. Keep emitting both so existing usages compile; +// new code should prefer `gray*`. +if primitive["gray"] != nil { + colorLines.append(" // MARK: - Compat / Neutral (alias of Gray)") + for s in scales { + colorLines.append(" static var neutral\(s): Color { .gray\(s) }") + } + colorLines.append("") +} + +// Primitive status colors (error / warning, including alpha variants — typo "slpha" normalized). +colorLines.append(" // MARK: - Primitive / Status") +for bucket in ["error", "warning"] { + guard let b = primitive[bucket] as? [String: Any] else { continue } + for (rawKey, value) in b.sorted(by: { $0.key < $1.key }) { + guard let node = value as? [String: Any], let v = node["$value"] else { continue } + let key = (rawKey == "slpha") ? "alpha" : rawKey + emitColor(name: camelCase([bucket, key]), value: v, into: &colorLines) + } +} + +colorLines.append("") + +// Semantic colors — flat names walked from the tree. +func walkColorLeaves( + _ node: [String: Any], + path: [String], + emit: (_ flatName: String, _ value: Any) -> Void +) { + for (key, value) in node.sorted(by: { $0.key < $1.key }) { + guard let dict = value as? [String: Any] else { continue } + if let type = dict["$type"] as? String, let val = dict["$value"] { + if type == "color" { + emit(camelCase(path + [key]), val) } + } else { + walkColorLeaves(dict, path: path + [key], emit: emit) } } - lines.append("") } -// semantic prefixed groups -let semGroups: [(jsonKey: String, swiftPrefix: String)] = [ +let semanticColorRoots: [(jsonKey: String, prefix: String)] = [ + ("background", "bg"), ("text", "text"), ("border", "border"), ("surface", "surface"), - ("background", "bg"), + ("action", "action"), + ("icon", "icon"), ] -for (key, prefix) in semGroups { - guard let group = semantic[key] as? [String: Any] else { continue } - lines.append(" // MARK: - Semantic / \(capitalizeFirst(key))") - for (rawName, val) in group.sorted(by: { $0.key < $1.key }) { - guard let node = val as? [String: Any], let v = valueOf(node) else { continue } - let name = "\(prefix)\(capitalizeFirst(rawName))" - if let h = hexAlpha(v) { - lines.append(" static var \(name): Color { \(colorBody(hex: h.hex, alpha: h.alpha)) }") - if h.alpha >= 1.0 { hexIndex[h.hex] = name } - } else if let aliasStr = v as? String, let target = aliasToSwiftName(aliasStr) { - lines.append(" static var \(name): Color { .\(target) }") - } - knownColorNames.insert(name) +for (root, prefix) in semanticColorRoots { + guard let group = semantic[root] as? [String: Any] else { continue } + colorLines.append(" // MARK: - Semantic / \(capitalizeFirst(root))") + walkColorLeaves(group, path: [prefix]) { name, val in + emitColor(name: name, value: val, into: &colorLines) } - lines.append("") + colorLines.append("") } -// status nested (status.error.error / status.error.Alpha / status.warning.warning / status.warning.Alpha) -if let status = semantic["status"] as? [String: Any] { - lines.append(" // MARK: - Semantic / Status") - for bucket in ["error", "warning"] { - guard let b = status[bucket] as? [String: Any] else { continue } - for (k, v) in b.sorted(by: { $0.key < $1.key }) { - guard let node = v as? [String: Any], let val = valueOf(node), let h = hexAlpha(val) else { continue } - let bucketCap = capitalizeFirst(bucket) - let name = (k == "Alpha") ? "status\(bucketCap)Alpha" : "status\(bucketCap)" - lines.append(" static var \(name): Color { \(colorBody(hex: h.hex, alpha: h.alpha)) }") - knownColorNames.insert(name) - if h.alpha >= 1.0 { hexIndex[h.hex] = name } +// Component colors — flat. +colorLines.append(" // MARK: - Component") +var componentNumberEntries: [(name: String, value: String)] = [] + +func walkComponent(_ node: [String: Any], path: [String]) { + for (key, value) in node.sorted(by: { $0.key < $1.key }) { + guard let dict = value as? [String: Any] else { continue } + if let type = dict["$type"] as? String, let val = dict["$value"] { + let name = camelCase(path + [key]) + switch type { + case "color": + emitColor(name: name, value: val, into: &colorLines) + case "sizing", "spacing", "borderRadius", "borderWidth", "number": + if let n = resolveNumber(val) { + componentNumberEntries.append((name, formatNumber(n))) + } + default: + break + } + } else { + walkComponent(dict, path: path + [key]) } } } -// Component colors — flat ShapeStyle 확장에 직접 합쳐 ComponentToken 중첩 enum 을 폐기. -let component = json["Component"] as! [String: Any] -var componentColorLines: [String] = [] -var componentNumberLines: [String] = [] -walkComponentFlat(component, pathPrefix: [], colorLines: &componentColorLines, numberLines: &componentNumberLines) -if !componentColorLines.isEmpty { - lines.append(" // MARK: - Component") - lines.append(contentsOf: componentColorLines) - lines.append("") +walkComponent(component, path: []) + +colorLines.append("}") +colorLines.append("") +try writeFile(colorOut, colorLines.joined(separator: "\n")) + +// MARK: - Spacing (primitive + semantic gap/padding/border-width) + +var spacingLines: [String] = [header, "", "import CoreGraphics", "", "public extension CGFloat {", ""] + +spacingLines.append(" // MARK: - Primitive Spacing") +var spacingPairs: [(value: Int, line: String)] = [] +for (key, value) in primitive { + guard key.hasPrefix("spacing-") || key.hasPrefix("spscing-") else { continue } + guard let node = value as? [String: Any], + let v = node["$value"], + let n = resolveNumber(v) else { continue } + let suffix = key.replacingOccurrences(of: "spacing-", with: "").replacingOccurrences(of: "spscing-", with: "") + guard let intVal = Int(suffix) else { continue } + spacingPairs.append((intVal, " static let s\(intVal): CGFloat = \(formatNumber(n))")) } -lines.append("}") -lines.append("") -try writeFile(colorOut, lines.joined(separator: "\n")) +for (_, line) in spacingPairs.sorted(by: { $0.value < $1.value }) { + spacingLines.append(line) +} -// MARK: - Radius +spacingLines.append("") -let radius = json["Radius"] as! [String: Any] -let radiusOrder = ["none", "default", "full"] -var rLines: [String] = [header, "", "import CoreGraphics", "", "public extension CGFloat {", ""] -rLines.append(" // MARK: - Radius") -for k in radiusOrder { - guard let node = radius[k] as? [String: Any], let v = valueOf(node), let n = v as? Double else { continue } - let safe = (k == "default") ? "`default`" : k - rLines.append(" static let \(safe): CGFloat = \(formatNumber(n))") +spacingLines.append(" // MARK: - Primitive Sizing") +for group in ["icon", "avatar", "control"] { + guard let g = primitive[group] as? [String: Any] else { continue } + for (sizeKey, value) in g.sorted(by: { $0.key < $1.key }) { + guard let node = value as? [String: Any], let v = node["$value"], let n = resolveNumber(v) else { continue } + spacingLines.append(" static let \(camelCase([group, sizeKey])): CGFloat = \(formatNumber(n))") + } } -rLines.append("}") -rLines.append("") -try writeFile(radiusOut, rLines.joined(separator: "\n")) +spacingLines.append("") -// MARK: - Spacing +if let gap = semantic["gap"] as? [String: Any] { + spacingLines.append(" // MARK: - Semantic Gap") + var gapPairs: [(Int, String)] = [] + for (key, value) in gap { + guard let node = value as? [String: Any], let v = node["$value"], let n = resolveNumber(v), + let intKey = Int(key) else { continue } + gapPairs.append((intKey, " static let gap\(intKey): CGFloat = \(formatNumber(n))")) + } + for (_, line) in gapPairs.sorted(by: { $0.0 < $1.0 }) { + spacingLines.append(line) + } + spacingLines.append("") +} -let spacing = json["Spacing"] as! [String: Any] -let spacingKeys = spacing.keys.compactMap(Int.init).sorted() -var sLines: [String] = [header, "", "import CoreGraphics", "", "public extension CGFloat {", ""] -sLines.append(" // MARK: - Spacing") -for k in spacingKeys { - guard let node = spacing[String(k)] as? [String: Any], let v = valueOf(node), let n = v as? Double else { continue } - sLines.append(" static let s\(k): CGFloat = \(formatNumber(n))") +if let padding = semantic["padding"] as? [String: Any] { + spacingLines.append(" // MARK: - Semantic Padding") + var paddingLines: [String] = [] + func walkPadding(_ node: [String: Any], path: [String]) { + for (key, value) in node.sorted(by: { $0.key < $1.key }) { + guard let dict = value as? [String: Any] else { continue } + if let type = dict["$type"] as? String, let val = dict["$value"] { + if type == "spacing", let n = resolveNumber(val) { + paddingLines.append(" static let \(camelCase(path + [key])): CGFloat = \(formatNumber(n))") + } + } else { + walkPadding(dict, path: path + [key]) + } + } + } + walkPadding(padding, path: ["padding"]) + spacingLines.append(contentsOf: paddingLines) + spacingLines.append("") +} + +spacingLines.append(" // MARK: - Semantic Border Width") +for variant in ["regular", "medium", "large"] { + let key = "border-width-\(variant)" + guard let node = semantic[key] as? [String: Any], let v = node["$value"], let n = resolveNumber(v) else { continue } + spacingLines.append(" static let \(camelCase(["border", "width", variant])): CGFloat = \(formatNumber(n))") } -sLines.append("}") -sLines.append("") -try writeFile(spacingOut, sLines.joined(separator: "\n")) +spacingLines.append("}") +spacingLines.append("") +try writeFile(spacingOut, spacingLines.joined(separator: "\n")) + +// MARK: - Radius + +var radiusLines: [String] = [header, "", "import CoreGraphics", "", "public extension CGFloat {", ""] +radiusLines.append(" // MARK: - Radius") +for key in ["radius-default", "radius-max"] { + guard let node = semantic[key] as? [String: Any], let v = node["$value"], let n = resolveNumber(v) else { continue } + let suffix = key.replacingOccurrences(of: "radius-", with: "") + radiusLines.append(" static let \(camelCase(["radius", suffix])): CGFloat = \(formatNumber(n))") +} -// MARK: - Component (numbers) +radiusLines.append("}") +radiusLines.append("") +try writeFile(radiusOut, radiusLines.joined(separator: "\n")) -// 색상은 위에서 ShapeStyle+.swift 에 이미 추가됨. 숫자만 CGFloat 확장으로 별도 출력. +// MARK: - Component numerics (flat) -if !componentNumberLines.isEmpty { - var cLines: [String] = [header, "", "import CoreGraphics", "", "public extension CGFloat {", ""] - cLines.append(" // MARK: - Component") - cLines.append(contentsOf: componentNumberLines) - cLines.append("}") - cLines.append("") - try writeFile(componentNumberOut, cLines.joined(separator: "\n")) +if !componentNumberEntries.isEmpty { + var cnLines: [String] = [header, "", "import CoreGraphics", "", "public extension CGFloat {", ""] + cnLines.append(" // MARK: - Component") + for entry in componentNumberEntries.sorted(by: { $0.name < $1.name }) { + cnLines.append(" static let \(entry.name): CGFloat = \(entry.value)") + } + cnLines.append("}") + cnLines.append("") + try writeFile(componentNumberOut, cnLines.joined(separator: "\n")) } -// MARK: - Component (nested ComponentToken) +// MARK: - ComponentToken (nested forwarding enum) + +func walkComponentNested( + _ node: [String: Any], + path: [String], + indent: String, + out: inout [String] +) { + let leafKeys = node.keys.sorted().filter { + guard let d = node[$0] as? [String: Any] else { return false } + return d["$type"] != nil + } + let groupKeys = node.keys.sorted().filter { + guard let d = node[$0] as? [String: Any] else { return false } + return d["$type"] == nil + } + for key in leafKeys { + guard let child = node[key] as? [String: Any], let type = child["$type"] as? String else { continue } + let flat = camelCase(path + [key]) + switch type { + case "color": + out.append("\(indent)public static var \(swiftKey(key)): Color { .\(flat) }") + case "sizing", "spacing", "borderRadius", "borderWidth", "number": + out.append("\(indent)public static var \(swiftKey(key)): CGFloat { .\(flat) }") + default: + continue + } + } + for (i, key) in groupKeys.enumerated() { + guard let child = node[key] as? [String: Any] else { continue } + if i == 0, !leafKeys.isEmpty { out.append("") } + if i > 0 { out.append("") } + out.append("\(indent)public enum \(capitalizeFirst(key)) {") + walkComponentNested(child, path: path + [key], indent: indent + " ", out: &out) + out.append("\(indent)}") + } +} -// flat ShapeStyle / CGFloat 확장을 forwarding 하는 구조적 접근용 enum. -// 그룹 단위 캡처/자동완성 탐색에 사용. 값의 source of truth 는 flat 정의 한 곳. var ctLines: [String] = [header, "", "import SwiftUI", "", "public enum ComponentToken {"] -walkComponentNested(component, pathPrefix: [], indent: " ", out: &ctLines) +walkComponentNested(component, path: [], indent: " ", out: &ctLines) ctLines.append("}") ctLines.append("") -try writeFile(componentOut, ctLines.joined(separator: "\n")) +try writeFile(componentTokenOut, ctLines.joined(separator: "\n")) -print("[token-gen] done.") +print("[token-gen] done. source=\(sourceDir)")