diff --git a/.github/workflows/gemini-code-review.yml b/.github/workflows/gemini-code-review.yml index e8fe47f..4df9541 100644 --- a/.github/workflows/gemini-code-review.yml +++ b/.github/workflows/gemini-code-review.yml @@ -119,65 +119,75 @@ jobs: 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 TRIPLE = "```"; + const promptLines = [ + "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 (이 프로젝트는 AGENTS.md 규칙을 따른다 — SubView 분리는 @ViewBuilder private func 또는 private var 형태가 정식이며 위반이 아니다. SubView 를 struct 로 강제하는 의견은 절대 내지 말 것); 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 " + TRIPLE + "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 등급은 사용하지 않는다. 빈 줄·공백·trailing comma·주석 줄 같은 포맷 의견은 절대 만들지 말 것.)", + "", + "Format: \"🔴 **[P1] Critical**\\n\\nActual comment content here...\"", + "", + "Ignore the following entirely — these are NOT review issues for this project:", + "- blank lines, whitespace, trailing commas, comment-only lines (P5 nitpicks)", + "- SubView 를 struct 로 분리하라는 의견 (이 프로젝트는 AGENTS.md 의 @ViewBuilder 패턴이 정식)", + "- import 순서, 줄 단위 들여쓰기", + "- description 같은 BaseTargetType 의 정식 프로퍼티 네이밍 의견", + "", + "Only report substantive issues — bugs, architecture violations, security/concurrency risks. Do NOT generate P5 nitpicks. 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~P4] Label**\\n\\n리뷰 코멘트 (한국어, 마크다운. 개선이 필요하면 " + TRIPLE + "suggestion 블록 포함)\"", + " }", + " ]", + "}", + "If no issues are found, return {\"summary\": \"...\", \"comments\": []}.", + "", + "", + annotated_diff, + "" + ]; + const prompt = promptLines.join("\n"); const result = await model.generateContent(prompt); const text = result.response.text(); fs.writeFileSync("review_result.json", text); diff --git a/AGENTS.md b/AGENTS.md index 2f62e84..119f4db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -474,13 +474,16 @@ public struct HomeCoordinator { // … State / Action / handleRoute … } +// swiftformat:disable extensionAccessControl extension HomeCoordinator { @Reducer public enum HomeScreen { case home(HomeFeature) case preVote(PreVoteFeature) + case chatRoom(ChatRoomFeature) } } +// swiftformat:enable extensionAccessControl extension HomeCoordinator.HomeScreen.State: Equatable {} @@ -489,15 +492,99 @@ public struct HomeCoordinator { @Reducer public enum HomeScreen { ... } // 안 됨 (매크로 인식 / Route 추론 깨짐) } + +// ❌ 금지 — 자동 포맷터가 `public extension { enum }` 으로 바꾸도록 방치 +public extension HomeCoordinator { + @Reducer + enum HomeScreen { ... } // @Reducer 매크로 확장이 internal 로 생성 → "must be declared public" 에러 +} ``` 규칙: - `XScreen` 은 `@Reducer public enum`. struct 로 바꾸지 말 것 - 본체와 분리된 **별도 extension** 안에 선언 +- **swiftformat 자동 변환 차단**: 해당 블록 앞뒤로 `// swiftformat:disable extensionAccessControl` / `// swiftformat:enable extensionAccessControl` 주석 페어 필수. + - 포맷터가 `public extension X { enum XScreen }` 으로 끌어올리면 `@Reducer` 매크로가 만드는 `State` / `Action` / `body` 가 internal 로 생성되어 `enum 'State' must be declared public because it matches a requirement in public protocol 'CaseReducer'` 빌드 에러가 난다. - `extension Coordinator.XScreen.State: Equatable {}` 보조 conformance 도 같이 유지 (Route diff 비교 필요) - 라우터 핸들러 (`routerAction`) 안에서 `state.routes.push/pop/goBack` 직접 호출은 OK, 단 `dismiss`/`submit` 같이 반복되는 종료 액션은 `.send(.view(.backAction))` 으로 일원화 - 레퍼런스: `HomeCoordinator`, `AuthCoordinator`, `MainTabCoordinator` +#### 🌐 switch 기반 computed property — 모든 case 에 `return` 명시 유지 (DomainType / BaseTargetType / DependencyKey 공통) + +`switch self { ... }` 만으로 값을 돌려주는 computed property 는 **모든 case 에 `return` 키워드를 명시한다**. 적용 범위: + +1. **`DomainType.url`** — `PieckeDomain` 같은 도메인 prefix 매핑 +2. **`BaseTargetType` 구현체의 `urlPath` / `method` / `parameters` / `task` / `sampleData`** — 모든 Moya TargetType switch +3. **`DependencyKey.liveValue` / `testValue`** — `UnifiedDI.resolve(...) ?? Default...()` 패턴 +4. 그 외 단일 표현식 switch 를 본문으로 갖는 computed property 전부 + +자동 포맷터의 `redundantReturn` 룰이 single-expression switch 에서 `return` 을 떼어내려 하지만, **새 case 추가 시 일부만 implicit / 일부는 explicit 으로 혼합되는 상태가 가장 깨지기 쉽다**. 새 case 추가 후엔 항상 기존 case 까지 같이 `return` 으로 정렬할 것. + +```swift +// ✅ DomainType — 모든 case return +extension PieckeDomain: DomainType { + public var url: String { + switch self { + case .auth: return "api/v1/auth/" + case .profile: return "api/v1/me/" + case .home: return "api/v1/home" + case .poll: return "api/v1/poll" + case .battle: return "api/v1/battles/" + } + } +} + +// ✅ BaseTargetType — urlPath / method / parameters 모두 return 명시 +extension BattleService: BaseTargetType { + public var urlPath: String { + switch self { + case let .preVote(battleId, _): + return BattleAPI.preVote(battleId: battleId).description + case let .scenario(battleId): + return BattleAPI.scenario(battleId: battleId).description + } + } + + public var method: Moya.Method { + switch self { + case .preVote: return .post + case .scenario: return .get + } + } + + public var parameters: [String: Any]? { + switch self { + case let .preVote(_, body): return body.toDictionary + case .scenario: return nil + } + } +} + +// ✅ DependencyKey — liveValue / testValue 도 return 명시 +public struct BattleRepositoryDependency: DependencyKey { + public static var liveValue: BattleInterface { + return UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() + } + public static var testValue: BattleInterface { + return UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() + } +} + +// ❌ 금지 — 포맷터가 떼어낸 implicit return 혼합 +public var method: Moya.Method { + switch self { + case .preVote: .post // ← 안 됨 + case .scenario: return .get // ← 혼합 상태 + } +} +``` + +규칙: +- 새 case 추가 후 포맷터가 기존 case 의 `return` 을 떼어냈다면 **PR 전에 직접 되돌려서 일관성 유지** +- 새 도메인 case (`.battle` 등) / 새 서비스 case (`.scenario` 등) / 새 DI 키 추가 시 모두 동일하게 `return ...` 형태로 작성 +- 포맷터가 반복적으로 깨면 해당 파일 또는 함수 블록에 `// swiftformat:disable redundantReturn` 디렉티브 페어 추가 검토 +- 레퍼런스: `PieckeDomain`, `BattleService`, `AuthService`, `BattleRepositoryDependency`, `HomeRepositoryDependency` + ### 📏 Swift 코딩 규칙 (`docs/agent/swift-coding-rules.md`) - Swift 스타일 가이드 - 에러 처리 패턴 @@ -664,6 +751,21 @@ tuist generate --no-open --path Projects/Shared/DesignSystem - `@swiftui-uikit-interop` — SwiftUI ↔ UIKit 상호 운용성 전문 - `@swift-concurrency` — Swift 6 Concurrency 및 async/await 전문 +### SwiftUI 전문 가이드 스킬 — `swiftui-expert-skill` +- **출처**: [AvdLee/SwiftUI-Agent-Skill](https://github.com/AvdLee/SwiftUI-Agent-Skill) (Agent Skills 오픈 포맷) +- **로컬 설치 위치** + - Claude Code: `~/.claude/plugins/SwiftUI-Agent-Skill/` + - Codex: `~/.codex/skills/swiftui-expert-skill/` + - Cursor 도 동일 폴더를 `Plugins` 가이드대로 등록하면 됨 +- **재설치 / 업데이트** + ```bash + rm -rf ~/.claude/plugins/SwiftUI-Agent-Skill ~/.codex/skills/swiftui-expert-skill + git clone https://github.com/AvdLee/SwiftUI-Agent-Skill.git ~/.claude/plugins/SwiftUI-Agent-Skill + cp -R ~/.claude/plugins/SwiftUI-Agent-Skill/swiftui-expert-skill ~/.codex/skills/swiftui-expert-skill + ``` +- **언제 호출**: SwiftUI 상태관리(`@Observable` / 프로퍼티 래퍼 선택), 뷰 컴포지션, 리스트·내비게이션·시트, Swift Charts, 애니메이션, macOS multi-window, iOS 26+ Liquid Glass, 접근성, Instruments 트레이스 분석. +- **호출 방법**: 프롬프트에 *"swiftui-expert skill 을 사용해 ..."* 형태로 지시하거나, `.trace` 경로/녹화 요청처럼 트리거 키워드가 들어오면 자동 활성화. + ### 자동 호출 키워드 다음 키워드 언급 시 **자동으로 성능 최적화 스킬 호출**: - `ifCaseLet`, `TCA`, `Effect`, `메모리 누수`, `성능`, `최적화` diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 36fed6e..b838c0a 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -21,13 +21,14 @@ public extension ModulePath { enum Presentations: String, CaseIterable { case Presentation case Splash + case Auth + case MainTab + case Home + case Chat public static let name: String = "Presentation" - case Auth - case MainTab - case Home } } diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 0576dda..37bb5fe 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -36,7 +36,8 @@ public final class AppDIManager: Sendable { // 🏗️ Repository 계층 (Clean Architecture + PFW) .register { AuthRepositoryImpl() as AuthInterface } .register { HomeRepositoryImpl() as HomeInterface } - .register { PollRepositoryImpl() as PollInterface } + .register { BattleRepositoryImpl() as BattleInterface } + .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } // .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 5d26192..5e09565 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -14,6 +14,7 @@ public enum PieckeDomain { case profile case home case poll + case battle } extension PieckeDomain: DomainType { @@ -30,7 +31,9 @@ extension PieckeDomain: DomainType { case .home: return"api/v1/home" case .poll: - return"api/v1/poll" + return "api/v1/poll" + case .battle: + return "api/v1/battles/" } } } diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift new file mode 100644 index 0000000..35d34d5 --- /dev/null +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -0,0 +1,23 @@ +// +// BattleAPI.swift +// API +// + +import Foundation + +public enum BattleAPI { + case detail(battleId: Int) + case preVote(battleId: Int) + case scenario(battleId: Int) + + public var description: String { + switch self { + case let .detail(battleId): + "\(battleId)" + case let .preVote(battleId): + "\(battleId)/votes/pre" + case let .scenario(battleId): + "\(battleId)/scenario" + } + } +} diff --git a/Projects/Data/API/Sources/Poll/PollAPI.swift b/Projects/Data/API/Sources/Poll/PollAPI.swift deleted file mode 100644 index 1ae62d7..0000000 --- a/Projects/Data/API/Sources/Poll/PollAPI.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// 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/Battle/DTO/BattleDetailDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift new file mode 100644 index 0000000..d10276c --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift @@ -0,0 +1,47 @@ +// +// BattleDetailDataDTO.swift +// Model +// + +import Foundation + +public struct BattleDetailDataDTO: Decodable { + public let battleInfo: BattleInfoDTO + public let description: String + public let shareUrl: String + public let userVoteStatus: String? + public let currentStep: String? + public let categoryTags: [BattleTagDTO] + public let philosopherTags: [BattleTagDTO] + public let valueTags: [BattleTagDTO] +} + +public struct BattleInfoDTO: Decodable { + public let battleId: Int + public let title: String + public let summary: String + public let thumbnailUrl: String + public let viewCount: Int + public let participantsCount: Int + public let audioDuration: Int + public let tags: [BattleTagDTO] + public let options: [BattleOptionDTO] +} + +public struct BattleOptionDTO: Decodable { + public let optionId: Int + public let label: String + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String + public let tags: [BattleTagDTO] +} + +public struct BattleTagDTO: Decodable { + public let tagId: Int + public let name: String + public let type: String +} + +public typealias BattleDetailResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift new file mode 100644 index 0000000..1468bed --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift @@ -0,0 +1,48 @@ +// +// BattleScenarioDataDTO.swift +// Model +// + +import Foundation + +public struct BattleScenarioDataDTO: Decodable { + public let battleId: Int + public let title: String + public let philosophers: [ScenarioPhilosopherDTO] + public let isInteractive: Bool + public let startNodeId: Int + public let recommendedPathKey: String + public let audios: [String: String] + public let nodes: [ScenarioNodeDTO] +} + +public struct ScenarioPhilosopherDTO: Decodable { + public let label: String + public let name: String + public let stance: String + public let imageUrl: String +} + +public struct ScenarioNodeDTO: Decodable { + public let nodeId: Int + public let nodeName: String + public let audioDuration: Int + public let autoNextNodeId: Int? + public let scripts: [ScenarioScriptDTO] + public let interactiveOptions: [ScenarioInteractiveOptionDTO]? +} + +public struct ScenarioScriptDTO: Decodable { + public let scriptId: Int + public let startTimeMs: Int + public let speakerType: String + public let speakerName: String + public let text: String +} + +public struct ScenarioInteractiveOptionDTO: Decodable { + public let label: String + public let nextNodeId: Int +} + +public typealias BattleScenarioResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift new file mode 100644 index 0000000..56b3aa0 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/PreVoteDataDTO.swift @@ -0,0 +1,13 @@ +// +// PreVoteDataDTO.swift +// Model +// + +import Foundation + +public struct PreVoteDataDTO: Decodable { + public let voteId: Int + public let status: String +} + +public typealias PreVoteResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift new file mode 100644 index 0000000..b356d46 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift @@ -0,0 +1,62 @@ +// +// BattleDetailDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleDetailDataDTO { + func toDomain() -> BattleDetail { + BattleDetail( + battleInfo: battleInfo.toDomain(), + description: description, + shareUrl: shareUrl, + userVoteStatus: userVoteStatus.map { UserVoteStatus(rawValue: $0) } ?? .none, + currentStep: currentStep.map { BattleStep(rawValue: $0) } ?? .none, + categoryTags: categoryTags.map { $0.toDomain() }, + philosopherTags: philosopherTags.map { $0.toDomain() }, + valueTags: valueTags.map { $0.toDomain() } + ) + } +} + +public extension BattleInfoDTO { + func toDomain() -> BattleInfo { + BattleInfo( + battleId: battleId, + title: title, + summary: summary, + thumbnailUrl: thumbnailUrl, + viewCount: viewCount, + participantsCount: participantsCount, + audioDuration: audioDuration, + tags: tags.map { $0.toDomain() }, + options: options.map { $0.toDomain() } + ) + } +} + +public extension BattleOptionDTO { + func toDomain() -> BattleOption { + BattleOption( + optionId: optionId, + label: label, + title: title, + stance: stance, + representative: representative, + imageUrl: imageUrl, + tags: tags.map { $0.toDomain() } + ) + } +} + +public extension BattleTagDTO { + func toDomain() -> BattleTag { + BattleTag( + tagId: tagId, + name: name, + type: TagType(rawValue: type) + ) + } +} diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift new file mode 100644 index 0000000..0f9f1e2 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift @@ -0,0 +1,59 @@ +// +// BattleScenarioDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleScenarioDataDTO { + func toDomain() -> BattleScenario { + BattleScenario( + battleId: battleId, + title: title, + philosophers: philosophers.map { $0.toDomain() }, + isInteractive: isInteractive, + startNodeId: startNodeId, + recommendedPathKey: RecommendedPathKey(rawValue: recommendedPathKey), + audios: audios, + nodes: nodes.map { $0.toDomain() } + ) + } +} + +public extension ScenarioPhilosopherDTO { + func toDomain() -> ScenarioPhilosopher { + ScenarioPhilosopher(label: label, name: name, stance: stance, imageUrl: imageUrl) + } +} + +public extension ScenarioNodeDTO { + func toDomain() -> ScenarioNode { + ScenarioNode( + nodeId: nodeId, + nodeName: nodeName, + audioDuration: audioDuration, + autoNextNodeId: autoNextNodeId, + scripts: scripts.map { $0.toDomain() }, + interactiveOptions: (interactiveOptions ?? []).map { $0.toDomain() } + ) + } +} + +public extension ScenarioScriptDTO { + func toDomain() -> ScenarioScript { + ScenarioScript( + scriptId: scriptId, + startTimeMs: startTimeMs, + speakerType: ScenarioSpeakerType(rawValue: speakerType), + speakerName: speakerName, + text: text + ) + } +} + +public extension ScenarioInteractiveOptionDTO { + func toDomain() -> ScenarioInteractiveOption { + ScenarioInteractiveOption(label: label, nextNodeId: nextNodeId) + } +} diff --git a/Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift new file mode 100644 index 0000000..2afaf86 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/PreVoteDataDTO+.swift @@ -0,0 +1,16 @@ +// +// PreVoteDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension PreVoteDataDTO { + func toDomain() -> PreVoteResult { + PreVoteResult( + voteId: voteId, + status: PreVoteResultStatus(rawValue: status) + ) + } +} diff --git a/Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift b/Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift deleted file mode 100644 index cb55823..0000000 --- a/Projects/Data/Model/Sources/Poll/DTO/PollDataDTO.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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 deleted file mode 100644 index a4c1e0f..0000000 --- a/Projects/Data/Model/Sources/Poll/Mapper/PollDataDTO+.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// 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/AudioPlayer/AudioPlayerRepositoryImpl.swift b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift new file mode 100644 index 0000000..08388a0 --- /dev/null +++ b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift @@ -0,0 +1,117 @@ +// +// AudioPlayerRepositoryImpl.swift +// Repository +// +// AVPlayer 기반 단일 오디오 플레이어 구현체. +// AudioPlayerInterface 를 만족하며, 채팅방 / 배틀 상세 등 한 화면당 하나의 음원이 +// 재생되는 환경을 가정한다. +// + +import AVFoundation +import DomainInterface +import Foundation + +public final class AudioPlayerRepositoryImpl: AudioPlayerInterface, @unchecked Sendable { + private let service: AudioPlayerService + + public init() { + service = AudioPlayerService.shared + } + + public func load(url: URL) async { + await service.load(url: url) + } + + public func play() async { + await service.play() + } + + public func pause() async { + await service.pause() + } + + public func seek(to time: TimeInterval) async { + await service.seek(to: time) + } + + public func duration() async -> TimeInterval { + await service.duration() + } + + public func currentTimes() -> AsyncStream { + AsyncStream { continuation in + let task = Task { @MainActor in + for await time in service.currentTimeStream() { + continuation.yield(time) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +@MainActor +final class AudioPlayerService { + static let shared = AudioPlayerService() + + private let player = AVPlayer() + private var timeContinuations: [UUID: AsyncStream.Continuation] = [:] + private var timeObserverToken: Any? + + private init() { + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try? AVAudioSession.sharedInstance().setActive(true) + installPeriodicObserver() + } + + private func installPeriodicObserver() { + let interval = CMTime(seconds: 0.25, preferredTimescale: 600) + timeObserverToken = player.addPeriodicTimeObserver( + forInterval: interval, + queue: .main + ) { [weak self] time in + let seconds = max(0, time.seconds.isFinite ? time.seconds : 0) + Task { @MainActor [weak self] in + self?.timeContinuations.values.forEach { $0.yield(seconds) } + } + } + } + + func load(url: URL) { + let item = AVPlayerItem(url: url) + player.replaceCurrentItem(with: item) + player.seek(to: .zero) + } + + func play() { player.play() } + func pause() { player.pause() } + + func seek(to time: TimeInterval) async { + await player.seek( + to: CMTime(seconds: max(0, time), preferredTimescale: 600), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + + func duration() async -> TimeInterval { + guard let item = player.currentItem else { return 0 } + if let duration = try? await item.asset.load(.duration) { + return duration.seconds.isFinite ? duration.seconds : 0 + } + return 0 + } + + func currentTimeStream() -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + timeContinuations[id] = continuation + continuation.onTermination = { @Sendable [weak self] _ in + Task { @MainActor in + self?.timeContinuations.removeValue(forKey: id) + } + } + } + } +} diff --git a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift index 3603724..5897a0a 100644 --- a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -41,15 +41,19 @@ public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { redirectUri: String?, idToken: String? ) async throws -> LoginEntity { + let body = OAuthLoginRequest( + authorizationCode: authorizationCode, + redirectUri: redirectUri, + idToken: idToken + ) + if let data = try? JSONEncoder().encode(body), + let json = String(data: data, encoding: .utf8) + { + Log.debug("[AuthRepository] POST /api/v1/auth/login/\(socialProvider.rawValue) body=\(json)") + } + let dto: LoginResponseDTO = try await provider.request( - .login( - provider: socialProvider, - body: OAuthLoginRequest( - authorizationCode: authorizationCode, - redirectUri: redirectUri, - idToken: idToken - ) - ) + .login(provider: socialProvider, body: body) ) guard let data = dto.data else { diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift new file mode 100644 index 0000000..4abab60 --- /dev/null +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -0,0 +1,68 @@ +// +// BattleRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchBattle(battleId: Int) async throws -> BattleDetail { + let dto: BattleDetailResponseDTO = try await provider.request( + .detail(battleId: battleId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "배틀 상세 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty battleDetail payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } + + public func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + let dto: PreVoteResponseDTO = try await provider.request( + .preVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "사전 투표 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty preVote payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } + + public func fetchScenario(battleId: Int) async throws -> BattleScenario { + let dto: BattleScenarioResponseDTO = try await provider.request( + .scenario(battleId: battleId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "시나리오 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty scenario payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } +} diff --git a/Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift b/Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift deleted file mode 100644 index d05597a..0000000 --- a/Projects/Data/Repository/Sources/Poll/PollRepositoryImpl.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// 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/Sources/Auth/AuthService.swift b/Projects/Data/Service/Sources/Auth/AuthService.swift index ba5d628..569aa4b 100644 --- a/Projects/Data/Service/Sources/Auth/AuthService.swift +++ b/Projects/Data/Service/Sources/Auth/AuthService.swift @@ -52,22 +52,22 @@ extension AuthService: BaseTargetType { public var method: Moya.Method { switch self { case .login, .refresh, .logout: - .post + return .post case .withdraw: - .delete + return .delete } } public var parameters: [String: Any]? { switch self { case let .login(_, body): - body.toDictionary + return body.toDictionary case .refresh: - nil + return nil case let .withdraw(token): - token.toDictionary(key: "token") + return token.toDictionary(key: "token") case .logout: - nil + return nil } } diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift new file mode 100644 index 0000000..988bea3 --- /dev/null +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -0,0 +1,62 @@ +// +// BattleService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum BattleService { + case detail(battleId: Int) + case preVote(battleId: Int, body: PreVoteRequest) + case scenario(battleId: Int) +} + +extension BattleService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .battle } + + public var urlPath: String { + switch self { + case let .detail(battleId): + BattleAPI.detail(battleId: battleId).description + case let .preVote(battleId, _): + BattleAPI.preVote(battleId: battleId).description + case let .scenario(battleId): + BattleAPI.scenario(battleId: battleId).description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .detail: + .get + case .preVote: + .post + case .scenario: + .get + } + } + + public var parameters: [String: Any]? { + switch self { + case .detail: + nil + case let .preVote(_, body): + body.toDictionary + case .scenario: + nil + } + } + + public var headers: [String: String]? { + APIHeader.baseHeader + } +} diff --git a/Projects/Data/Service/Sources/Battle/PreVoteRequest.swift b/Projects/Data/Service/Sources/Battle/PreVoteRequest.swift new file mode 100644 index 0000000..dda3592 --- /dev/null +++ b/Projects/Data/Service/Sources/Battle/PreVoteRequest.swift @@ -0,0 +1,16 @@ +// +// PreVoteRequest.swift +// Service +// +// Created by Wonji Suh on 5/21/26. +// + +import Foundation + +public struct PreVoteRequest: Encodable { + public let optionId: Int + + public init(optionId: Int) { + self.optionId = optionId + } +} diff --git a/Projects/Data/Service/Sources/Home/HomeService.swift b/Projects/Data/Service/Sources/Home/HomeService.swift index c34ac4f..574205f 100644 --- a/Projects/Data/Service/Sources/Home/HomeService.swift +++ b/Projects/Data/Service/Sources/Home/HomeService.swift @@ -24,7 +24,7 @@ extension HomeService: BaseTargetType { public var urlPath: String { switch self { case .home: - HomeAPI.home.description + return HomeAPI.home.description } } @@ -33,13 +33,13 @@ extension HomeService: BaseTargetType { public var method: Moya.Method { switch self { case .home: - .get + return .get } } public var parameters: [String: Any]? { nil } public var headers: [String: String]? { - APIHeader.baseHeader // 인증 헤더 포함 (액세스 토큰) + return APIHeader.baseHeader // 인증 헤더 포함 (액세스 토큰) } } diff --git a/Projects/Data/Service/Sources/Poll/PollService.swift b/Projects/Data/Service/Sources/Poll/PollService.swift deleted file mode 100644 index d77d8f0..0000000 --- a/Projects/Data/Service/Sources/Poll/PollService.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// 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/AudioPlayer/AudioPlayerInterface.swift b/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift new file mode 100644 index 0000000..e8f9f2e --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift @@ -0,0 +1,48 @@ +// +// AudioPlayerInterface.swift +// DomainInterface +// +// 채팅방 / 배틀 상세 등 한 번에 하나의 음원만 재생되는 환경을 위한 단일 플레이어 인터페이스. +// + +import Dependencies +import Foundation +import WeaveDI + +public protocol AudioPlayerInterface: Sendable { + func load(url: URL) async + func play() async + func pause() async + func seek(to time: TimeInterval) async + func duration() async -> TimeInterval + func currentTimes() -> AsyncStream +} + +public struct DefaultAudioPlayerImpl: AudioPlayerInterface { + public init() {} + public func load(url _: URL) async {} + public func play() async {} + public func pause() async {} + public func seek(to _: TimeInterval) async {} + public func duration() async -> TimeInterval { 0 } + public func currentTimes() -> AsyncStream { AsyncStream { _ in } } +} + +public struct AudioPlayerDependency: DependencyKey { + public static var liveValue: AudioPlayerInterface { + UnifiedDI.resolve(AudioPlayerInterface.self) ?? DefaultAudioPlayerImpl() + } + + public static var testValue: AudioPlayerInterface { + UnifiedDI.resolve(AudioPlayerInterface.self) ?? DefaultAudioPlayerImpl() + } + + public static var previewValue: AudioPlayerInterface = liveValue +} + +public extension DependencyValues { + var audioPlayer: AudioPlayerInterface { + get { self[AudioPlayerDependency.self] } + set { self[AudioPlayerDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift index 80897ec..5d3a531 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift @@ -26,11 +26,11 @@ public protocol AuthInterface: Sendable { /// Auth Repository 의 DependencyKey 구조체 public struct AuthRepositoryDependency: DependencyKey { public static var liveValue: AuthInterface { - UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() + return UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() } public static var testValue: AuthInterface { - UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() + return UnifiedDI.resolve(AuthInterface.self) ?? DefaultAuthRepositoryImpl() } public static var previewValue: AuthInterface = liveValue diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift new file mode 100644 index 0000000..09f7d8b --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -0,0 +1,33 @@ +// +// BattleInterface.swift +// DomainInterface +// + +import Entity +import Foundation +import WeaveDI + +public protocol BattleInterface: Sendable { + func fetchBattle(battleId: Int) async throws -> BattleDetail + func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult + func fetchScenario(battleId: Int) async throws -> BattleScenario +} + +public struct BattleRepositoryDependency: DependencyKey { + public static var liveValue: BattleInterface { + UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() + } + + public static var testValue: BattleInterface { + UnifiedDI.resolve(BattleInterface.self) ?? DefaultBattleRepositoryImpl() + } + + public static var previewValue: BattleInterface = liveValue +} + +public extension DependencyValues { + var battleRepository: BattleInterface { + get { self[BattleRepositoryDependency.self] } + set { self[BattleRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift new file mode 100644 index 0000000..5d5894a --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -0,0 +1,51 @@ +// +// DefaultBattleRepositoryImpl.swift +// DomainInterface +// + +import Entity +import Foundation + +public struct DefaultBattleRepositoryImpl: BattleInterface { + public init() {} + + public func fetchBattle(battleId _: Int) async throws -> BattleDetail { + BattleDetail( + battleInfo: BattleInfo( + battleId: 0, + title: "", + summary: "", + thumbnailUrl: "", + viewCount: 0, + participantsCount: 0, + audioDuration: 0, + tags: [], + options: [] + ), + description: "", + shareUrl: "", + userVoteStatus: .none, + currentStep: .none, + categoryTags: [], + philosopherTags: [], + valueTags: [] + ) + } + + public func submitPreVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { + PreVoteResult(voteId: 0, status: .none) + } + + public func fetchScenario(battleId _: Int) async throws -> BattleScenario { + BattleScenario( + battleId: 0, + title: "", + philosophers: [], + isInteractive: false, + startNodeId: 0, + recommendedPathKey: .common, + audios: [:], + nodes: [] + ) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift b/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift index 348a97d..86f2d8c 100644 --- a/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Google/GoogleOAuthProviderInterface.swift @@ -17,11 +17,11 @@ public protocol GoogleOAuthProviderInterface: Sendable { /// Google OAuth Provider 의 DependencyKey 구조체 public struct GoogleOAuthProviderDependency: DependencyKey { public static var liveValue: GoogleOAuthProviderInterface { - UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() + return UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() } public static var testValue: GoogleOAuthProviderInterface { - UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() + return UnifiedDI.resolve(GoogleOAuthProviderInterface.self) ?? MockGoogleOAuthProvider() } public static var previewValue: GoogleOAuthProviderInterface = testValue diff --git a/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift b/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift index 27e1a84..aba3cc9 100644 --- a/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift @@ -18,11 +18,11 @@ public protocol HomeInterface: Sendable { public struct HomeRepositoryDependency: DependencyKey { public static var liveValue: HomeInterface { - UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() + return UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() } public static var testValue: HomeInterface { - UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() + return UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() } public static var previewValue: HomeInterface = liveValue diff --git a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift index c892a39..0d0de7c 100644 --- a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthProviderInterface.swift @@ -17,11 +17,11 @@ public protocol KakaoOAuthProviderInterface: Sendable { /// Kakao OAuth Provider의 DependencyKey 구조체 public struct KakaoOAuthProviderDependency: DependencyKey { public static var liveValue: KakaoOAuthProviderInterface { - UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() + return UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() } public static var testValue: KakaoOAuthProviderInterface { - UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() + return UnifiedDI.resolve(KakaoOAuthProviderInterface.self) ?? MockKakaoOAuthProvider() } public static var previewValue: KakaoOAuthProviderInterface = testValue diff --git a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift index 4d29635..822fbc2 100644 --- a/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift +++ b/Projects/Domain/DomainInterface/Sources/Kakao/KakaoOAuthRepositoryProtocol.swift @@ -18,7 +18,7 @@ public protocol KakaoOAuthInterface: Sendable { public struct KakaoOAuthRepositoryDependencyKey: DependencyKey { public static var liveValue: KakaoOAuthInterface { - UnifiedDI.resolve(KakaoOAuthInterface.self) ?? MockKakaoOAuthRepository() + return UnifiedDI.resolve(KakaoOAuthInterface.self) ?? MockKakaoOAuthRepository() } public static var previewValue: KakaoOAuthInterface = MockKakaoOAuthRepository() diff --git a/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift index 8488e42..ffefaff 100644 --- a/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift @@ -20,11 +20,11 @@ public protocol KeychainManaging: Sendable { public struct KeychainManagerDependency: DependencyKey { public static var liveValue: KeychainManaging { - UnifiedDI.resolve(KeychainManaging.self) ?? InMemoryKeychainManager() + return UnifiedDI.resolve(KeychainManaging.self) ?? InMemoryKeychainManager() } public static var testValue: KeychainManaging { - InMemoryKeychainManager() + return InMemoryKeychainManager() } public static var previewValue: KeychainManaging = testValue diff --git a/Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift deleted file mode 100644 index b0034fd..0000000 --- a/Projects/Domain/DomainInterface/Sources/Poll/DefaultPollRepositoryImpl.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// 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 deleted file mode 100644 index 81df867..0000000 --- a/Projects/Domain/DomainInterface/Sources/Poll/PollInterface.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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/BattleDetail.swift b/Projects/Domain/Entity/Sources/Home/BattleDetail.swift new file mode 100644 index 0000000..f5c26c7 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/BattleDetail.swift @@ -0,0 +1,131 @@ +// +// BattleDetail.swift +// Entity +// +// `GET /api/v1/battles/{battleId}` 응답 도메인 모델. +// + +import Foundation + +public struct BattleDetail: Equatable, Identifiable { + public let battleInfo: BattleInfo + public let description: String + public let shareUrl: String + public let userVoteStatus: UserVoteStatus + public let currentStep: BattleStep + public let categoryTags: [BattleTag] + public let philosopherTags: [BattleTag] + public let valueTags: [BattleTag] + + public var id: Int { battleInfo.battleId } + + public init( + battleInfo: BattleInfo, + description: String, + shareUrl: String, + userVoteStatus: UserVoteStatus, + currentStep: BattleStep, + categoryTags: [BattleTag], + philosopherTags: [BattleTag], + valueTags: [BattleTag] + ) { + self.battleInfo = battleInfo + self.description = description + self.shareUrl = shareUrl + self.userVoteStatus = userVoteStatus + self.currentStep = currentStep + self.categoryTags = categoryTags + self.philosopherTags = philosopherTags + self.valueTags = valueTags + } +} + +public struct BattleInfo: Equatable, Identifiable { + public let battleId: Int + public let title: String + public let summary: String + public let thumbnailUrl: String + public let viewCount: Int + public let participantsCount: Int + public let audioDuration: Int + public let tags: [BattleTag] + public let options: [BattleOption] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + summary: String, + thumbnailUrl: String, + viewCount: Int, + participantsCount: Int, + audioDuration: Int, + tags: [BattleTag], + options: [BattleOption] + ) { + self.battleId = battleId + self.title = title + self.summary = summary + self.thumbnailUrl = thumbnailUrl + self.viewCount = viewCount + self.participantsCount = participantsCount + self.audioDuration = audioDuration + self.tags = tags + self.options = options + } +} + +public struct BattleOption: Equatable, Identifiable, Hashable { + public let optionId: Int + public let label: String + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String + public let tags: [BattleTag] + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String, + title: String, + stance: String, + representative: String, + imageUrl: String, + tags: [BattleTag] + ) { + self.optionId = optionId + self.label = label + self.title = title + self.stance = stance + self.representative = representative + self.imageUrl = imageUrl + self.tags = tags + } +} + +public enum UserVoteStatus: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case pro = "PRO" + case con = "CON" + case unknown + + public init(rawValue: String) { + self = UserVoteStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} + +public enum BattleStep: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case preVote = "PRE_VOTE" + case listening = "LISTENING" + case postVote = "POST_VOTE" + case finished = "FINISHED" + case unknown + + public init(rawValue: String) { + self = BattleStep.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/BattleScenario.swift b/Projects/Domain/Entity/Sources/Home/BattleScenario.swift new file mode 100644 index 0000000..1c22953 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/BattleScenario.swift @@ -0,0 +1,141 @@ +// +// BattleScenario.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/scenario` 응답 도메인 모델. +// + +import Foundation + +public struct BattleScenario: Equatable, Identifiable { + public let battleId: Int + public let title: String + public let philosophers: [ScenarioPhilosopher] + public let isInteractive: Bool + public let startNodeId: Int + public let recommendedPathKey: RecommendedPathKey + public let audios: [String: String] + public let nodes: [ScenarioNode] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + philosophers: [ScenarioPhilosopher], + isInteractive: Bool, + startNodeId: Int, + recommendedPathKey: RecommendedPathKey, + audios: [String: String], + nodes: [ScenarioNode] + ) { + self.battleId = battleId + self.title = title + self.philosophers = philosophers + self.isInteractive = isInteractive + self.startNodeId = startNodeId + self.recommendedPathKey = recommendedPathKey + self.audios = audios + self.nodes = nodes + } +} + +public struct ScenarioPhilosopher: Equatable, Hashable, Identifiable { + public let label: String + public let name: String + public let stance: String + public let imageUrl: String + + public var id: String { label } + + public init(label: String, name: String, stance: String, imageUrl: String) { + self.label = label + self.name = name + self.stance = stance + self.imageUrl = imageUrl + } +} + +public struct ScenarioNode: Equatable, Identifiable { + public let nodeId: Int + public let nodeName: String + public let audioDuration: Int + public let autoNextNodeId: Int? + public let scripts: [ScenarioScript] + public let interactiveOptions: [ScenarioInteractiveOption] + + public var id: Int { nodeId } + + public init( + nodeId: Int, + nodeName: String, + audioDuration: Int, + autoNextNodeId: Int?, + scripts: [ScenarioScript], + interactiveOptions: [ScenarioInteractiveOption] + ) { + self.nodeId = nodeId + self.nodeName = nodeName + self.audioDuration = audioDuration + self.autoNextNodeId = autoNextNodeId + self.scripts = scripts + self.interactiveOptions = interactiveOptions + } +} + +public struct ScenarioScript: Equatable, Identifiable, Hashable { + public let scriptId: Int + public let startTimeMs: Int + public let speakerType: ScenarioSpeakerType + public let speakerName: String + public let text: String + + public var id: Int { scriptId } + + public init( + scriptId: Int, + startTimeMs: Int, + speakerType: ScenarioSpeakerType, + speakerName: String, + text: String + ) { + self.scriptId = scriptId + self.startTimeMs = startTimeMs + self.speakerType = speakerType + self.speakerName = speakerName + self.text = text + } +} + +public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { + public let label: String + public let nextNodeId: Int + + public var id: String { "\(label)-\(nextNodeId)" } + + public init(label: String, nextNodeId: Int) { + self.label = label + self.nextNodeId = nextNodeId + } +} + +public enum ScenarioSpeakerType: String, Equatable, Hashable, CaseIterable { + case a = "A" + case b = "B" + case narrator = "NARRATOR" + case philosopher = "PHILOSOPHER" + case unknown + + public init(rawValue: String) { + self = ScenarioSpeakerType.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} + +public enum RecommendedPathKey: String, Equatable, Hashable, CaseIterable { + case common = "COMMON" + case unknown + + public init(rawValue: String) { + self = RecommendedPathKey.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/BattleTag.swift b/Projects/Domain/Entity/Sources/Home/BattleTag.swift index 005de43..f585003 100644 --- a/Projects/Domain/Entity/Sources/Home/BattleTag.swift +++ b/Projects/Domain/Entity/Sources/Home/BattleTag.swift @@ -26,6 +26,7 @@ public enum TagType: String, Equatable, Hashable, Decodable { case philosopher = "PHILOSOPHER" case category = "CATEGORY" case era = "ERA" + case value = "VALUE" case unknown public init(rawValue: String) { @@ -33,6 +34,7 @@ public enum TagType: String, Equatable, Hashable, Decodable { case "PHILOSOPHER": self = .philosopher case "CATEGORY": self = .category case "ERA": self = .era + case "VALUE": self = .value default: self = .unknown } } diff --git a/Projects/Domain/Entity/Sources/Home/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/ChatMessage.swift new file mode 100644 index 0000000..9c55d51 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/ChatMessage.swift @@ -0,0 +1,116 @@ +// +// ChatMessage.swift +// Entity +// +// 채팅방(`/Users/suhwonji/Desktop/와이어프레임/채팅방.pdf` + .pen `k3lIx`) 메시지 모델. +// + +import Foundation + +public enum ChatSpeakerSide: Equatable, Hashable { + case left + case right + case center +} + +public struct ChatSpeaker: Equatable, Identifiable, Hashable { + public let label: String? + public let name: String + public let imageURL: String? + public let side: ChatSpeakerSide + + public var id: String { "\(label ?? name)-\(side)" } + + public init( + label: String? = nil, + name: String, + imageURL: String? = nil, + side: ChatSpeakerSide + ) { + self.label = label + self.name = name + self.imageURL = imageURL + self.side = side + } +} + +public struct ChatMessage: Equatable, Identifiable, Hashable { + public let messageId: UUID + public let speaker: ChatSpeaker + public let text: String + /// 시나리오 스크립트의 시작 시각 (밀리초). 오디오 재생 진행도에 따라 + /// 활성 메시지로 자동 스크롤할 때 사용. mock 데이터 / 시간 정보가 없는 + /// 경우엔 nil. + public let startTimeMs: Int? + + public var id: UUID { messageId } + + public init( + messageId: UUID = UUID(), + speaker: ChatSpeaker, + text: String, + startTimeMs: Int? = nil + ) { + self.messageId = messageId + self.speaker = speaker + self.text = text + self.startTimeMs = startTimeMs + } +} + +public struct ChatRoomBundle: Equatable { + public let battleTitle: String + public let totalDuration: TimeInterval + public let leftSpeaker: ChatSpeaker + public let rightSpeaker: ChatSpeaker + public let messages: [ChatMessage] + + public init( + battleTitle: String, + totalDuration: TimeInterval, + leftSpeaker: ChatSpeaker, + rightSpeaker: ChatSpeaker, + messages: [ChatMessage] + ) { + self.battleTitle = battleTitle + self.totalDuration = totalDuration + self.leftSpeaker = leftSpeaker + self.rightSpeaker = rightSpeaker + self.messages = messages + } +} + +public extension ChatRoomBundle { + static let mock: ChatRoomBundle = { + let plato = ChatSpeaker( + label: "A", + name: "플라톤", + imageURL: "https://picke.store/api/v1/resources/images/PHILOSOPHER/plato.png", + side: .left + ) + let sartre = ChatSpeaker( + label: "B", + name: "사르트르", + imageURL: "https://picke.store/api/v1/resources/images/PHILOSOPHER/sartre.png", + side: .right + ) + return ChatRoomBundle( + battleTitle: "뒤샹의 변기, 예술인가 도발인가", + totalDuration: 268, + leftSpeaker: plato, + rightSpeaker: sartre, + messages: [ + ChatMessage(speaker: plato, text: "이건 기만입니다. 하늘 아래 모든 사물은 그에 걸맞은 완벽한 목적과 형상, 즉 '이데아'를 가지고 있습니다."), + ChatMessage(speaker: plato, text: "변기의 이데아는 '배설물을 처리하는 것'이지, 감상하는 것이 아닙니다."), + ChatMessage(speaker: plato, text: "사물의 본질을 왜곡하여 대중을 혼란에 빠뜨리는 것은 진리를 모독하는 행위입니다."), + ChatMessage(speaker: sartre, text: "플라톤 선생님, 당신은 사물에 '영혼'이 미리 정해져 있다고 믿는군요. 하지만 사물은 그저 그곳에 존재할 뿐입니다."), + ChatMessage(speaker: sartre, text: "인간이 그것을 어떻게 사용하고 어떤 의미를 부여하느냐에 따라 본질은 언제든 바뀔 수 있습니다."), + ChatMessage( + speaker: sartre, + text: "뒤샹이 이 물건을 '샘'이라고 부르기로 선택한 순간, 이 물체의 본질은 배설 도구에서 예술 작품으로 재탄생한 것입니다. 실존은 본질에 앞서니까요." + ), + ChatMessage(speaker: plato, text: "예술이란 이데아를 모방하려는 숭고한 노력입니다. 화가는 붓질을 통해, 조각가는 망치질을 통해 그 본질에 가까워지려 애쓰죠."), + ] + ) + }() +} diff --git a/Projects/Domain/Entity/Sources/Home/HomeBundle.swift b/Projects/Domain/Entity/Sources/Home/HomeBundle.swift index eb47860..db73bd0 100644 --- a/Projects/Domain/Entity/Sources/Home/HomeBundle.swift +++ b/Projects/Domain/Entity/Sources/Home/HomeBundle.swift @@ -47,15 +47,6 @@ public extension HomeBundle { newBattles: NewBattle.mocks ) - var replacingEmptySectionsWithMocks: HomeBundle { - HomeBundle( - newNotice: newNotice, - heroes: heroes.isEmpty ? HeroBattle.mocks : heroes, - hotBattles: hotBattles.isEmpty ? HotBattle.mocks : hotBattles, - bestBattles: bestBattles.isEmpty ? BestBattle.mocks : bestBattles, - quizzes: quizzes.isEmpty ? [.mock] : quizzes, - votes: votes.isEmpty ? [.mock] : votes, - newBattles: newBattles.isEmpty ? NewBattle.mocks : newBattles - ) - } + /// 서버 응답을 그대로 노출 — 빈 섹션은 UI 에서 숨김. + var replacingEmptySectionsWithMocks: HomeBundle { self } } diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift b/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift index 4fa840f..c60b040 100644 --- a/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift +++ b/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift @@ -43,13 +43,22 @@ public struct PreVoteBattle: Equatable, Identifiable { } public struct PreVoteOption: Equatable, Identifiable, Hashable { - public let philosopher: PhilosopherAvatar + public let optionId: Int + public let representative: String + public let imageURL: String public let stance: String - public var id: String { philosopher.rawValue } + public var id: Int { optionId } - public init(philosopher: PhilosopherAvatar, stance: String) { - self.philosopher = philosopher + public init( + optionId: Int, + representative: String, + imageURL: String, + stance: String + ) { + self.optionId = optionId + self.representative = representative + self.imageURL = imageURL self.stance = stance } } @@ -73,7 +82,17 @@ public extension PreVoteBattle { 누군가는 현대 미술의 혁명이라고 부릅니다. 과연 이 변기의 '진짜 모습'은 무엇일까요? """, - leftOption: .init(philosopher: .plato, stance: "변기는 변기다"), - rightOption: .init(philosopher: .sartre, stance: "예술이다") + leftOption: .init( + optionId: 1, + representative: "플라톤", + imageURL: "https://picke.store/api/v1/resources/images/PHILOSOPHER/plato.png", + stance: "변기는 변기다" + ), + rightOption: .init( + optionId: 2, + representative: "사르트르", + imageURL: "https://picke.store/api/v1/resources/images/PHILOSOPHER/sartre.png", + stance: "예술이다" + ) ) } diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteResult.swift b/Projects/Domain/Entity/Sources/Home/PreVoteResult.swift new file mode 100644 index 0000000..3e4baf1 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/PreVoteResult.swift @@ -0,0 +1,27 @@ +// +// PreVoteResult.swift +// Entity +// + +import Foundation + +public struct PreVoteResult: Equatable, Hashable { + public let voteId: Int + public let status: PreVoteResultStatus + + public init(voteId: Int, status: PreVoteResultStatus) { + self.voteId = voteId + self.status = status + } +} + +public enum PreVoteResultStatus: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case created = "CREATED" + case updated = "UPDATED" + case unknown + + public init(rawValue: String) { + self = PreVoteResultStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Poll/PollDetail.swift b/Projects/Domain/Entity/Sources/Poll/PollDetail.swift deleted file mode 100644 index cb81e59..0000000 --- a/Projects/Domain/Entity/Sources/Poll/PollDetail.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// 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/Presentation/Chat/Project.swift b/Projects/Presentation/Chat/Project.swift new file mode 100644 index 0000000..8a84566 --- /dev/null +++ b/Projects/Presentation/Chat/Project.swift @@ -0,0 +1,21 @@ +import DependencyPackagePlugin +import DependencyPlugin +import Foundation +import ProjectDescription +import ProjectTemplatePlugin + +let project = Project.makeAppModule( + name: "Chat", + bundleId: .appBundleID(name: ".Chat"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Domain(implements: .UseCase), + .Shared(implements: .DesignSystem), + .SPM.composableArchitecture, + .SPM.tcaFlow, + .SPM.kingfisher, + .SPM.logMarco, + ], + sources: ["Sources/**"] +) diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift new file mode 100644 index 0000000..6b28d2e --- /dev/null +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -0,0 +1,417 @@ +// +// ChatRoomFeature.swift +// Home +// +// Created by Wonji Suh on 5/19/26. +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import DomainInterface +import Entity +import LogMacro + +@Reducer +public struct ChatRoomFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var bundle: ChatRoomBundle = .mock + public var scenario: BattleScenario? + public var isPlaying: Bool = false + public var currentTime: TimeInterval = 0 + public var playerDuration: TimeInterval = 0 + public var battleId: Int = 0 + public var isLoadingScenario: Bool = false + /// 한 번 끝까지 재생되어야 시킹(드래그) 허용 + public var hasFinishedListening: Bool = false + public var hasPresentedFinalVoteAlert: Bool = false + @Presents public var customAlert: CustomAlertState? + + /// 현재 재생 중인 시나리오 노드 id (없으면 startNodeId 폴백) + public var currentNodeId: Int? + /// 선택지 영역에서 사용자가 탭한 옵션 label + public var selectedOptionLabel: String? + + public var totalDuration: TimeInterval { + if playerDuration > 0 { return playerDuration } + guard let scenario else { return bundle.totalDuration } + let nodesTotal = scenario.nodes.reduce(0) { $0 + $1.audioDuration } + return nodesTotal > 0 ? TimeInterval(nodesTotal) : bundle.totalDuration + } + + public var battleTitle: String { scenario?.title ?? bundle.battleTitle } + + public var messages: [ChatMessage] { + guard let scenario else { return bundle.messages } + return scenario.nodes.flatMap { node in + node.scripts.map { script in + ChatMessage( + messageId: Self.scriptUUID(scriptId: script.scriptId), + speaker: speaker(for: script, in: scenario), + text: script.text, + startTimeMs: script.startTimeMs + ) + } + } + } + + /// 같은 scriptId 면 동일한 UUID 를 반환해 ForEach 의 id 가 매 렌더링마다 + /// 흔들리지 않도록 한다 (자동 스크롤 target 안정화). + private static func scriptUUID(scriptId: Int) -> UUID { + let hex = String(format: "%012X", scriptId) + return UUID(uuidString: "00000000-0000-0000-0000-\(hex)") ?? UUID() + } + + /// 현재 재생 시점에 해당하는 메시지 id. `currentTime` 이상의 startTimeMs 를 + /// 가지지 않은 마지막 메시지를 활성으로 본다. + public var activeMessageId: UUID? { + let currentMs = Int(currentTime * 1000) + var active: ChatMessage? + for message in messages { + guard let start = message.startTimeMs else { continue } + if start <= currentMs { active = message } else { break } + } + return active?.id ?? messages.first?.id + } + + public var audioUrl: String? { + guard let scenario else { return nil } + if let url = scenario.audios[scenario.recommendedPathKey.rawValue] { return url } + return scenario.audios.values.first + } + + public var canScrub: Bool { hasFinishedListening } + + private func speaker(for script: ScenarioScript, in scenario: BattleScenario) -> ChatSpeaker { + switch script.speakerType { + case .a: + return speaker(label: "A", side: .left, fallbackName: script.speakerName, in: scenario) + case .b: + return speaker(label: "B", side: .right, fallbackName: script.speakerName, in: scenario) + case .narrator: + return ChatSpeaker(name: script.speakerName, side: .center) + case .philosopher, .unknown: + if let philosopher = scenario.philosophers.first(where: { $0.name == script.speakerName }) { + let side: ChatSpeakerSide = philosopher.label == "B" ? .right : .left + return ChatSpeaker( + label: philosopher.label, + name: philosopher.name, + imageURL: philosopher.imageUrl, + side: side + ) + } + return ChatSpeaker(name: script.speakerName, side: .center) + } + } + + private func speaker( + label: String, + side: ChatSpeakerSide, + fallbackName: String, + in scenario: BattleScenario + ) -> ChatSpeaker { + guard let philosopher = scenario.philosophers.first(where: { $0.label == label }) else { + return ChatSpeaker(label: label, name: fallbackName, side: side) + } + return ChatSpeaker( + label: philosopher.label, + name: philosopher.name, + imageURL: philosopher.imageUrl, + side: side + ) + } + + public var currentNode: ScenarioNode? { + guard let scenario else { return nil } + let target = currentNodeId ?? scenario.startNodeId + return scenario.nodes.first { $0.nodeId == target } + } + + public var interactiveOptions: [ScenarioInteractiveOption] { + currentNode?.interactiveOptions ?? [] + } + + public var visibleOptions: [ScenarioInteractiveOption] { + interactiveOptions + } + + public var shouldShowOptions: Bool { + !visibleOptions.isEmpty + } + + public var isConfirmEnabled: Bool { selectedOptionLabel != nil } + + public init(battleId: Int = 0) { + self.battleId = battleId + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backButtonTapped + case refreshTapped + case togglePlayTapped + case seekBackwardTapped + case seekForwardTapped + case scrub(TimeInterval) + case optionTapped(String) + case confirmOptionTapped + } + + public enum AsyncAction: Equatable { + case fetchScenario + case loadAudio(URL) + case subscribePlayer + } + + public enum InnerAction: Equatable { + case scenarioResponse(Result) + case playerTimeUpdated(TimeInterval) + case playerDurationUpdated(TimeInterval) + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + nonisolated enum CancelID: Hashable { + case fetchScenario + case audioObserver + } + + @Dependency(\.battleRepository) private var battleRepository + @Dependency(\.audioPlayer) private var audioPlayer + + 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 .scope(scopeAction): + handleScopeAction(state: &state, action: scopeAction) + case let .delegate(delegateAction): + handleDelegateAction(state: &state, action: delegateAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension ChatRoomFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + let needsFetch = state.scenario == nil && !state.isLoadingScenario + let subscribe: Effect = .send(.async(.subscribePlayer)) + return needsFetch + ? subscribe.merge(with: .send(.async(.fetchScenario))) + : subscribe + + case .backButtonTapped: + return .run { [player = audioPlayer] send in + await player.pause() + await send(.delegate(.dismiss)) + } + + case .refreshTapped: + state.currentTime = 0 + state.isPlaying = false + return .run { [player = audioPlayer] _ in + await player.pause() + await player.seek(to: 0) + } + + case .togglePlayTapped: + state.isPlaying.toggle() + let playing = state.isPlaying + return .run { [player = audioPlayer] _ in + if playing { await player.play() } else { await player.pause() } + } + + case .seekBackwardTapped: + guard state.canScrub else { return .none } + let target = max(0, state.currentTime - 15) + state.currentTime = target + return .run { [player = audioPlayer] _ in + await player.seek(to: target) + } + + case .seekForwardTapped: + guard state.canScrub else { return .none } + let target = min(state.totalDuration, state.currentTime + 15) + state.currentTime = target + return .run { [player = audioPlayer] _ in + await player.seek(to: target) + } + + case let .scrub(time): + guard state.canScrub else { return .none } + let target = min(max(0, time), state.totalDuration) + state.currentTime = target + return .run { [player = audioPlayer] _ in + await player.seek(to: target) + } + + case let .optionTapped(label): + state.selectedOptionLabel = (state.selectedOptionLabel == label) ? nil : label + return .none + + case .confirmOptionTapped: + guard let label = state.selectedOptionLabel, + let option = state.visibleOptions.first(where: { $0.label == label }) + else { return .none } + state.currentNodeId = option.nextNodeId + state.selectedOptionLabel = nil + state.currentTime = 0 + state.hasFinishedListening = false + state.isPlaying = false + return .run { [player = audioPlayer] _ in + await player.pause() + await player.seek(to: 0) + } + } + } + + private func handleAsyncAction(state: inout State, action: AsyncAction) -> Effect { + switch action { + case .fetchScenario: + state.isLoadingScenario = true + let battleId = state.battleId + return .run { [repository = battleRepository] send in + let result = await Result { + try await repository.fetchScenario(battleId: battleId) + } + .mapError(AuthError.from) + return await send(.inner(.scenarioResponse(result))) + } + .cancellable(id: CancelID.fetchScenario, cancelInFlight: true) + + case let .loadAudio(url): + state.currentTime = 0 + state.playerDuration = 0 + state.isPlaying = true + return .run { [player = audioPlayer] send in + await player.load(url: url) + let duration = await player.duration() + if duration > 0 { + await send(.inner(.playerDurationUpdated(duration))) + } + await player.play() + } + + case .subscribePlayer: + return .run { [player = audioPlayer] send in + for await time in player.currentTimes() { + await send(.inner(.playerTimeUpdated(time))) + } + } + .cancellable(id: CancelID.audioObserver, cancelInFlight: true) + } + } + + private func handleInnerAction(state: inout State, action: InnerAction) -> Effect { + switch action { + case let .scenarioResponse(result): + state.isLoadingScenario = false + switch result { + case let .success(scenario): + state.scenario = scenario + if state.currentNodeId == nil { + state.currentNodeId = scenario.startNodeId + } + if let urlString = scenario.audios[scenario.recommendedPathKey.rawValue] + ?? scenario.audios.values.first, + let url = URL(string: urlString) + { + return .send(.async(.loadAudio(url))) + } + return .none + case let .failure(error): + Log.error("[ChatRoomFeature] fetchScenario failed: \(error.localizedDescription)") + return .none + } + + case let .playerTimeUpdated(time): + state.currentTime = time + if state.totalDuration > 0, + time >= state.totalDuration - 0.5, + !state.hasFinishedListening + { + state.hasFinishedListening = true + state.isPlaying = false + if !state.hasPresentedFinalVoteAlert { + state.hasPresentedFinalVoteAlert = true + state.customAlert = .finalVote() + } + } + return .none + + case let .playerDurationUpdated(duration): + state.playerDuration = duration + return .none + } + } + + private func handleScopeAction(state: inout State, action: ScopeAction) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case let .presented(customAlertAction): + switch customAlertAction { + case .confirmTapped: + state.customAlert = nil + return .none + case .cancelTapped: + state.customAlert = nil + state.currentTime = 0 + state.isPlaying = true + return .run { [player = audioPlayer] _ in + await player.seek(to: 0) + await player.play() + } + } + case .dismiss: + state.customAlert = nil + return .none + } + } + } + + private func handleDelegateAction(state _: inout State, action: DelegateAction) -> Effect { + switch action { + case .dismiss: + .none + } + } +} diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift new file mode 100644 index 0000000..1ad2571 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift @@ -0,0 +1,385 @@ +// +// ChatRoomView.swift +// Home +// +// Created by Wonji Suh on 5/19/26. +// +// .pen `채팅방` (k3lIx) + 와이어프레임 매핑 — 헤더 + 메시지 리스트 + 오디오 재생바. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Kingfisher + +@ViewAction(for: ChatRoomFeature.self) +public struct ChatRoomView: View { + @Bindable public var store: StoreOf + private static let bubbleMaxWidth: CGFloat = 222 + private static let avatarSize: CGFloat = 32.7 + private static let avatarImageWidth: CGFloat = 24 + private static let avatarImageHeight: CGFloat = 28 + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Group { + if shouldShowSkeleton { + ChatRoomSkeletonView() + } else { + VStack(spacing: 0) { + navigationBar() + messageList() + if store.shouldShowOptions { + interactiveOptionsSection() + } + playerBar() + } + } + } + .background(Color.beige200.ignoresSafeArea()) + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } + + private var shouldShowSkeleton: Bool { + store.isLoadingScenario && store.scenario == nil + } +} + +// MARK: - Navigation + +extension ChatRoomView { + @ViewBuilder + private func navigationBar() -> some View { + PickeNavigationBar( + onBack: { send(.backButtonTapped) } + ) { + Button { send(.refreshTapped) } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 18, weight: .semibold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + .overlay(alignment: .center) { + Text(store.battleTitle) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.neutral800) + .lineLimit(1) + .padding(.horizontal, 56) + } + .foregroundStyle(.neutral800) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Messages + +extension ChatRoomView { + @ViewBuilder + private func messageList() -> some View { + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + ForEach(groupedMessages, id: \.id) { group in + messageGroup(group) + .id(group.messages.last?.id ?? group.id) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: store.activeMessageId) { _, activeMessageId in + guard let target = scrollTargetId(for: activeMessageId) else { return } + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(target, anchor: .center) + } + } + } + } + + /// 활성 메시지가 속한 SpeakerGroup 의 마지막 메시지 id 로 스크롤한다. + private func scrollTargetId(for activeId: UUID?) -> UUID? { + guard let activeId else { return nil } + for group in groupedMessages where group.messages.contains(where: { $0.id == activeId }) { + return group.messages.last?.id ?? activeId + } + return activeId + } + + private var groupedMessages: [SpeakerGroup] { + var result: [SpeakerGroup] = [] + for message in store.messages { + if let last = result.last, last.speaker == message.speaker { + var updated = last + updated.messages.append(message) + result[result.count - 1] = updated + } else { + result.append(SpeakerGroup(speaker: message.speaker, messages: [message])) + } + } + return result + } + + @ViewBuilder + private func messageGroup(_ group: SpeakerGroup) -> some View { + switch group.speaker.side { + case .left: + HStack(alignment: .top, spacing: 8) { + avatar(group.speaker) + bubbleColumn(speaker: group.speaker, messages: group.messages) + Spacer(minLength: 40) + } + + case .right: + HStack(alignment: .top, spacing: 8) { + Spacer(minLength: 40) + bubbleColumn(speaker: group.speaker, messages: group.messages) + avatar(group.speaker) + } + + case .center: + VStack(spacing: 6) { + ForEach(group.messages) { message in + narratorBubble(text: message.text) + } + } + .frame(maxWidth: .infinity) + } + } + + @ViewBuilder + private func avatar(_ speaker: ChatSpeaker) -> some View { + KFImage(URL(string: speaker.imageURL ?? "")) + .placeholder { + SkeletonView(cornerRadius: Self.avatarSize / 2) + } + .resizable() + .scaledToFit() + .frame(width: Self.avatarImageWidth, height: Self.avatarImageHeight) + .frame(width: Self.avatarSize, height: Self.avatarSize) + .background(.beige600, in: Circle()) + } + + @ViewBuilder + private func bubbleColumn(speaker: ChatSpeaker, messages: [ChatMessage]) -> some View { + VStack(alignment: speaker.side == .left ? .leading : .trailing, spacing: 6) { + Text(speaker.name) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.neutral500) + .padding(.horizontal, 4) + + VStack(alignment: .leading, spacing: 6) { + ForEach(messages) { message in + bubble(text: message.text, side: speaker.side) + } + } + } + .frame(maxWidth: Self.bubbleMaxWidth, alignment: speaker.side == .left ? .leading : .trailing) + } + + @ViewBuilder + private func bubble(text: String, side: ChatSpeakerSide) -> some View { + Text(text) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral500) + .lineSpacing(13 * 0.4) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: Self.bubbleMaxWidth, alignment: .leading) + .background( + side == .left ? Color.beige50 : Color.beige400, + in: RoundedRectangle(cornerRadius: 2) + ) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(side == .left ? Color.beige600 : Color.beige700, lineWidth: 1) + ) + } + + @ViewBuilder + private func narratorBubble(text: String) -> some View { + Text(text) + .pretendardFont(family: .Regular, size: 12) + .foregroundStyle(.neutral400) + .lineSpacing(12 * 0.4) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: 280) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + + private struct SpeakerGroup: Equatable, Identifiable { + let speaker: ChatSpeaker + var messages: [ChatMessage] + + var id: UUID { messages.first?.id ?? UUID() } + } +} + +// MARK: - Interactive Options + +extension ChatRoomView { + @ViewBuilder + private func interactiveOptionsSection() -> some View { + VStack(spacing: 12) { + optionsHeader() + optionsList() + confirmButton() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(.beige100) + } + + @ViewBuilder + private func optionsHeader() -> some View { + HStack(spacing: 10) { + Rectangle() + .fill(.neutral200) + .frame(height: 0.5) + Text("당신의 입장을 선택해주세요") + .pretendardFont(family: .Bold, size: 13) + .foregroundStyle(.neutral800) + .fixedSize() + Rectangle() + .fill(.neutral200) + .frame(height: 0.5) + } + } + + @ViewBuilder + private func optionsList() -> some View { + VStack(spacing: 9) { + ForEach(store.visibleOptions, id: \.label) { option in + optionCard(option) + } + } + } + + @ViewBuilder + private func optionCard(_ option: ScenarioInteractiveOption) -> some View { + let isSelected = store.selectedOptionLabel == option.label + + Button { + send(.optionTapped(option.label)) + } label: { + Text(option.label) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(isSelected ? .neutral800 : .neutral300) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(.beige400, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(isSelected ? .borderSecondarySelected : .borderBeigeDefault, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func confirmButton() -> some View { + CustomButton( + action: { send(.confirmOptionTapped) }, + title: "입장 선택하기", + config: CustomButtonConfig.primary(.large, height: 42), + isEnable: store.isConfirmEnabled + ) + } +} + +// MARK: - Player Bar + +extension ChatRoomView { + @ViewBuilder + private func playerBar() -> some View { + VStack(spacing: 16) { + progressBar() + AudioPlayerControlView( + isPlaying: .constant(store.isPlaying), + onBackward: { send(.seekBackwardTapped) }, + onTogglePlay: { send(.togglePlayTapped) }, + onForward: { send(.seekForwardTapped) } + ) + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + .background(.beige50) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } + + @ViewBuilder + private func progressBar() -> some View { + VStack(spacing: 8) { + GeometryReader { proxy in + let progress = store.totalDuration > 0 + ? CGFloat(store.currentTime / store.totalDuration) + : 0 + ZStack(alignment: .leading) { + Capsule().fill(.beige600).frame(height: 4) + Capsule().fill(.primary500).frame(width: proxy.size.width * progress, height: 4) + Circle() + .fill(.primary500) + .frame(width: 16, height: 16) + .offset(x: max(0, proxy.size.width * progress - 8)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + guard store.canScrub, + proxy.size.width > 0, + store.totalDuration > 0 else { return } + let ratio = min(max(value.location.x / proxy.size.width, 0), 1) + send(.scrub(store.totalDuration * Double(ratio))) + } + ) + .allowsHitTesting(store.canScrub) + .opacity(store.canScrub ? 1.0 : 0.6) + } + .frame(height: 16) + + HStack { + Text(timeString(store.currentTime)) + Spacer() + Text(timeString(store.totalDuration)) + } + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + } + } + + private func timeString(_ seconds: TimeInterval) -> String { + let total = Int(seconds) + return String(format: "%d:%02d", total / 60, total % 60) + } +} diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift new file mode 100644 index 0000000..836ef81 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/ChatRoom/View/Components/ChatRoomSkeletonView.swift @@ -0,0 +1,128 @@ +// +// ChatRoomSkeletonView.swift +// Home +// +// picke.pen `채팅방 - Skeleton Loader` (2CRRg) 매핑. +// 메시지 리스트 + 재생바 placeholder 를 shimmer 애니메이션과 함께 렌더링한다. +// + +import SwiftUI + +import DesignSystem + +struct ChatRoomSkeletonView: View { + var body: some View { + VStack(spacing: 0) { + navigationBarSkeleton() + messageListSkeleton() + playerBarSkeleton() + } + .background(Color.beige50.ignoresSafeArea()) + } +} + +// MARK: - Navigation + +private extension ChatRoomSkeletonView { + @ViewBuilder + func navigationBarSkeleton() -> some View { + HStack(spacing: 12) { + SkeletonView(cornerRadius: 6) + .frame(width: 20, height: 24) + Spacer() + SkeletonView(cornerRadius: 6) + .frame(width: 24, height: 24) + } + .padding(.horizontal, 20) + .frame(height: 60) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Messages + +private extension ChatRoomSkeletonView { + @ViewBuilder + func messageListSkeleton() -> some View { + VStack(alignment: .leading, spacing: 24) { + leftGroup() + rightGroup() + leftGroup() + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + func leftGroup() -> some View { + HStack(alignment: .top, spacing: 8) { + SkeletonView(cornerRadius: 20) + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 8) { + SkeletonView(cornerRadius: 6) + .frame(width: 37, height: 20) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 54) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 36) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 36) + } + Spacer(minLength: 0) + } + } + + @ViewBuilder + func rightGroup() -> some View { + HStack(alignment: .top, spacing: 8) { + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 8) { + SkeletonView(cornerRadius: 6) + .frame(width: 49, height: 20) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 54) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 36) + SkeletonView(cornerRadius: 6) + .frame(width: 256, height: 54) + } + SkeletonView(cornerRadius: 20) + .frame(width: 40, height: 40) + } + } +} + +// MARK: - Player bar + +private extension ChatRoomSkeletonView { + @ViewBuilder + func playerBarSkeleton() -> some View { + VStack(spacing: 16) { + SkeletonView(cornerRadius: 6) + .frame(height: 18) + + HStack(alignment: .top, spacing: 32) { + SkeletonView(cornerRadius: 6) + .frame(width: 24, height: 55) + SkeletonView(cornerRadius: 6) + .frame(width: 55, height: 55) + SkeletonView(cornerRadius: 6) + .frame(width: 24, height: 55) + } + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + .background(.beige50) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift new file mode 100644 index 0000000..746a8e6 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -0,0 +1,122 @@ +// +// ChatCoordinator.swift +// Chat +// +// 채팅방 모듈 진입점. battleId 를 받아 PreVote → ChatRoom 흐름을 자체적으로 라우팅한다. +// 향후 사후 투표 결과 / 공유 등 후속 화면이 필요해지면 ChatScreen enum 에 case 만 추가. +// + +import Foundation + +import ComposableArchitecture +import TCAFlow + +@FlowCoordinator(screen: "ChatScreen", navigation: true) +public struct ChatCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init(battleId: Int = 0) { + routes = [.root(.preVote(.init(battleId: battleId)), embedInNavigationView: true)] + } + } + + @CasePathable + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + public enum AsyncAction: Equatable {} + public enum InnerAction: Equatable {} + public enum NavigationAction: Equatable {} + + public enum DelegateAction: Equatable { + case dismiss + } + + func handleRoute(state: inout State, action: Action) -> Effect { + switch action { + case let .router(routeAction): + routerAction(state: &state, action: routeAction) + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + case .async, .inner, .navigation: + .none + case let .delegate(delegateAction): + handleDelegateAction(state: &state, action: delegateAction) + } + } +} + +extension ChatCoordinator { + private func routerAction( + state: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + case .routeAction(_, action: .preVote(.delegate(.dismiss))): + return .send(.delegate(.dismiss)) + + case let .routeAction(_, action: .preVote(.delegate(.voteSubmitted(battleId, _)))): + state.routes.push(.chatRoom(.init(battleId: battleId))) + return .none + + case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): + return .send(.view(.backAction)) + + default: + return .none + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backAction: + state.routes.goBack() + return .none + case .backToRootAction: + state.routes.goBackToRoot() + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} + +// swiftformat:disable extensionAccessControl +extension ChatCoordinator { + @Reducer + public enum ChatScreen { + case preVote(PreVoteFeature) + case chatRoom(ChatRoomFeature) + } +} + +// swiftformat:enable extensionAccessControl + +extension ChatCoordinator.ChatScreen.State: Equatable {} diff --git a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift new file mode 100644 index 0000000..e724ee2 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift @@ -0,0 +1,32 @@ +// +// ChatCoordinatorView.swift +// Chat +// + +import Foundation + +import SwiftUI + +import ComposableArchitecture +import TCAFlow + +public struct ChatCoordinatorView: View { + @Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in + switch screen.case { + case let .preVote(preVoteStore): + PreVoteView(store: preVoteStore) + .toolbar(.hidden, for: .tabBar) + case let .chatRoom(chatRoomStore): + ChatRoomView(store: chatRoomStore) + .toolbar(.hidden, for: .tabBar) + } + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift new file mode 100644 index 0000000..3f3e06c --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -0,0 +1,249 @@ +// +// 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? + public var battleDetail: BattleDetail? + public var selectedOptionId: Int? + public var isLoading: Bool = false + public var isSubmitting: Bool = false + public var shareItem: ShareItem? + public var battleId: Int + + public var isPrimaryButtonEnabled: Bool { + selectedOptionId != nil && !isSubmitting + } + + public init(battleId: Int = 0, battle: PreVoteBattle? = nil) { + self.battleId = battleId + self.battle = battle + } + } + + /// 공유 시트 트리거. `.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(optionId: Int) + case primaryButtonTapped + } + + public enum AsyncAction: Equatable { + case fetchBattleDetail + case submitPreVote(battleId: Int, optionId: Int) + } + + public enum InnerAction: Equatable { + case battleDetailResponse(Result) + case preVoteResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + case voteSubmitted(battleId: Int, result: PreVoteResult) + } + + nonisolated enum CancelID: Hashable { + case fetchBattleDetail + case submitPreVote + } + + @Dependency(\.battleRepository) private var battleRepository + + 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.battleDetail == nil, + state.battle == nil, + !state.isLoading + else { return .none } + return .send(.async(.fetchBattleDetail)) + + case .backButtonTapped: + return .send(.delegate(.dismiss)) + + case .shareTapped: + let title = state.battleDetail?.battleInfo.title ?? state.battle?.titleLine1 ?? "" + let url = state.battleDetail?.shareUrl + ?? "https://picke.store/battles/\(state.battleId)" + state.shareItem = ShareItem(items: [title, url]) + return .none + + case let .optionTapped(optionId): + state.selectedOptionId = (state.selectedOptionId == optionId) ? nil : optionId + return .none + + case .primaryButtonTapped: + guard let optionId = state.selectedOptionId else { return .none } + state.isSubmitting = true + return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchBattleDetail: + state.isLoading = true + let battleId = state.battleId + return .run { [repository = battleRepository] send in + let result = await Result { + try await repository.fetchBattle(battleId: battleId) + } + .mapError(AuthError.from) + return await send(.inner(.battleDetailResponse(result))) + } + .cancellable(id: CancelID.fetchBattleDetail, cancelInFlight: true) + + case let .submitPreVote(battleId, optionId): + return .run { [repository = battleRepository] send in + let result = await Result { + try await repository.submitPreVote(battleId: battleId, optionId: optionId) + } + .mapError(AuthError.from) + return await send(.inner(.preVoteResponse(result))) + } + .cancellable(id: CancelID.submitPreVote, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .battleDetailResponse(result): + state.isLoading = false + switch result { + case let .success(detail): + state.battleDetail = detail + state.battle = makeBattle(from: detail) + case let .failure(error): + Log.error("[PreVoteFeature] fetchBattle failed: \(error.localizedDescription)") + } + return .none + + case let .preVoteResponse(result): + state.isSubmitting = false + switch result { + case let .success(voteResult): + return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: voteResult))) + case let .failure(error): + Log.error("[PreVoteFeature] submitPreVote failed: \(error.localizedDescription)") + return .send(.delegate(.voteSubmitted(battleId: state.battleId, result: .init(voteId: 0, status: .created)))) + } + } + } + + /// API 로 받은 BattleDetail 을 화면 모델 PreVoteBattle 로 매핑. + /// 옵션 0, 1 만 좌/우 카드에 매핑 (label A→left, B→right). + private func makeBattle(from detail: BattleDetail) -> PreVoteBattle? { + let info = detail.battleInfo + let mapped = info.options.map { option in + PreVoteOption( + optionId: option.optionId, + representative: option.representative, + imageURL: option.imageUrl, + stance: option.title + ) + } + guard let leftOption = mapped[safe: 0], + let rightOption = mapped[safe: 1] + else { + Log.error("[PreVoteFeature] 서버 option 데이터 부족 count=\(mapped.count)") + return nil + } + + return PreVoteBattle( + battleId: info.battleId, + backgroundImageURL: info.thumbnailUrl, + tags: detail.categoryTags.map { "#\($0.name)" }, + titleLine1: info.title, + titleLine2: "", + summary: detail.description.isEmpty ? info.summary : detail.description, + leftOption: leftOption, + rightOption: rightOption + ) + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss, .voteSubmitted: + .none + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift b/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift new file mode 100644 index 0000000..e66d811 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift @@ -0,0 +1,102 @@ +// +// PreVoteSkeletonView.swift +// Chat +// +// PreVoteView 의 로딩 상태 placeholder. +// .pen `사전 투표창 - Skeleton Loader` 를 의미 단위(hero / 카피 / 옵션 / CTA) 로 재구성한다. +// + +import SwiftUI + +import DesignSystem + +struct PreVoteSkeletonView: View { + var body: some View { + VStack(spacing: 0) { + hero + contentSection + Spacer(minLength: 0) + ctaButton + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.beige50.ignoresSafeArea()) + } +} + +// MARK: - Hero + +private extension PreVoteSkeletonView { + @ViewBuilder + var hero: some View { + SkeletonView(cornerRadius: 6) + .frame(height: 329) + .overlay(alignment: .top) { + SkeletonView(cornerRadius: 6) + .frame(height: 60) + .padding(.top, 70) + } + } +} + +// MARK: - Content + +private extension PreVoteSkeletonView { + @ViewBuilder + var contentSection: some View { + VStack(alignment: .leading, spacing: 16) { + tagsRow + titleBlock + summaryBlock + optionsRow + } + .padding(.horizontal, 16) + .padding(.top, 24) + } + + @ViewBuilder + var tagsRow: some View { + HStack(spacing: 8) { + SkeletonView(cornerRadius: 6) + .frame(width: 29, height: 17) + SkeletonView(cornerRadius: 6) + .frame(width: 49, height: 17) + } + } + + @ViewBuilder + var titleBlock: some View { + SkeletonView(cornerRadius: 6) + .frame(width: 167, height: 68) + } + + @ViewBuilder + var summaryBlock: some View { + SkeletonView(cornerRadius: 6) + .frame(width: 235.5, height: 61.43) + } + + @ViewBuilder + var optionsRow: some View { + ZStack { + HStack(spacing: 8) { + SkeletonView(cornerRadius: 6) + SkeletonView(cornerRadius: 6) + } + .frame(height: 105.72) + + SkeletonView(cornerRadius: 7.5) + .frame(width: 15, height: 15) + } + } +} + +// MARK: - CTA + +private extension PreVoteSkeletonView { + @ViewBuilder + var ctaButton: some View { + SkeletonView(cornerRadius: 6) + .frame(width: 87, height: 24) + .padding(.bottom, 40) + } +} diff --git a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift similarity index 54% rename from Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift rename to Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index 9ed57d9..683621f 100644 --- a/Projects/Presentation/Home/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -32,6 +32,23 @@ public struct PreVoteView: View { .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) + .overlay(alignment: .bottom) { + if !shouldShowSkeleton { + primaryButton + .padding(.horizontal, Self.ctaHorizontalPadding) + .padding(.bottom, Self.ctaBottomSpacing) + } + } + .overlay(alignment: .top) { + if !shouldShowSkeleton { + navigationBar + .background(Color.clear) + .padding(.top, 12) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .zIndex(10) + } + } .onAppear { send(.onAppear) } .sheet(item: $store.shareItem) { item in ShareSheet(items: item.items) @@ -41,30 +58,52 @@ public struct PreVoteView: View { } private var shouldShowSkeleton: Bool { - store.isLoading && store.poll == nil + store.isLoading || store.battle == nil } @ViewBuilder private var loadedContent: some View { - ZStack(alignment: .top) { - backgroundImage + if let battle = store.battle { + GeometryReader { proxy in + ZStack(alignment: .top) { + backgroundImage(battle) + .frame(width: proxy.size.width) - VStack(spacing: 0) { - navigationBar - Spacer(minLength: 0) - contentArea + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + Color.clear + .frame(height: Self.contentOverlapTopOffset) + + contentArea(battle) + } + .frame(width: proxy.size.width) + } + .scrollBounceBehavior(.basedOnSize) + } + .ignoresSafeArea(edges: .top) } + } else { + PreVoteSkeletonView() } } + + private static let contentOverlapTopOffset: CGFloat = 280 + private static let rootContentSpacing: CGFloat = 40 + private static let contentToOptionSpacing: CGFloat = 32 + private static let optionCardHeight: CGFloat = 106 + private static let ctaHeight: CGFloat = 52 + private static let ctaBottomSpacing: CGFloat = 40 + private static let ctaHorizontalPadding: CGFloat = 16 + private static let contentBottomSpacing: CGFloat = ctaHeight + ctaBottomSpacing + rootContentSpacing } // MARK: - Background extension PreVoteView { @ViewBuilder - private var backgroundImage: some View { + private func backgroundImage(_ battle: PreVoteBattle) -> some View { ZStack { - if let urlString = store.battle.backgroundImageURL, + if let urlString = battle.backgroundImageURL, let url = URL(string: urlString) { KFImage(url) @@ -77,10 +116,9 @@ extension PreVoteView { Color.black.opacity(0.4) } + .frame(maxWidth: .infinity) .frame(height: 512) .clipped() - .frame(maxWidth: .infinity, alignment: .top) - .ignoresSafeArea(edges: .top) } } @@ -89,16 +127,26 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder private var navigationBar: some View { - PickeNavigationBar( - onBack: { send(.backButtonTapped) } - ) { + HStack { + Button { send(.backButtonTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .regular)) + .frame(width: 20, height: 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Spacer() + Button { send(.shareTapped) } label: { Image(systemName: "square.and.arrow.up") - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: 24, weight: .regular)) .frame(width: 24, height: 24) + .contentShape(Rectangle()) } .buttonStyle(.plain) } + .padding(.horizontal, 16) .foregroundStyle(.beige50) } } @@ -107,15 +155,15 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private var contentArea: some View { - VStack(spacing: 40) { - contentSection - optionSection - primaryButton + private func contentArea(_ battle: PreVoteBattle) -> some View { + VStack(spacing: Self.contentToOptionSpacing) { + contentSection(battle) + optionSection(battle) } .padding(.horizontal, 16) .padding(.top, 80) - .padding(.bottom, 40) + .padding(.bottom, Self.contentBottomSpacing) + .frame(maxWidth: .infinity) .background( LinearGradient( stops: [ @@ -131,21 +179,21 @@ extension PreVoteView { } @ViewBuilder - private var contentSection: some View { + private func contentSection(_ battle: PreVoteBattle) -> some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 20) { - tagsRow - titleText + tagsRow(battle) + titleText(battle) } - summaryText + summaryText(battle) } .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder - private var tagsRow: some View { + private func tagsRow(_ battle: PreVoteBattle) -> some View { HStack(spacing: 9) { - ForEach(store.battle.tags, id: \.self) { tag in + ForEach(battle.tags, id: \.self) { tag in Text(tag) .pretendardFont(family: .SemiBold, size: 12) .foregroundStyle(.primary500) @@ -157,23 +205,25 @@ extension PreVoteView { } @ViewBuilder - private var titleText: some View { - Text("\(store.battle.titleLine1)\n\(store.battle.titleLine2)") + private func titleText(_ battle: PreVoteBattle) -> some View { + Text([battle.titleLine1, battle.titleLine2].filter { !$0.isEmpty }.joined(separator: "\n")) .pretendardFont(family: .Bold, size: 24) .foregroundStyle(.neutral500) .kerning(-0.6) .lineSpacing(24 * 0.4) - .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + .lineLimit(nil) .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder - private var summaryText: some View { - Text(store.battle.summary) + private func summaryText(_ battle: PreVoteBattle) -> some View { + Text(battle.summary) .pretendardFont(family: .Regular, size: 13) .foregroundStyle(.neutral400) .lineSpacing(13 * 0.4) - .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + .lineLimit(nil) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -182,55 +232,66 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private var optionSection: some View { + private func optionSection(_ battle: PreVoteBattle) -> some View { ZStack { HStack(spacing: 8) { - optionCard(store.battle.leftOption) - optionCard(store.battle.rightOption) + optionCard(battle.leftOption) + optionCard(battle.rightOption) } + .frame(maxWidth: .infinity) vsBadge } } @ViewBuilder private func optionCard(_ option: PreVoteOption) -> some View { - let isSelected = store.selectedSide == option.philosopher + let isSelected = store.selectedOptionId == option.optionId return Button { - send(.optionTapped(option.philosopher)) + send(.optionTapped(optionId: option.optionId)) } label: { VStack(spacing: 12) { - avatarView(option.philosopher) + avatarView(imageURL: option.imageURL) VStack(spacing: 2) { Text(option.stance) .pretendardFont(family: .SemiBold, size: 14) .foregroundStyle(.neutral600) .kerning(-0.35) + .lineLimit(2) + .minimumScaleFactor(0.85) .multilineTextAlignment(.center) - Text(option.philosopher.rawValue) + Text(option.representative) .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral300) + .lineLimit(1) + .minimumScaleFactor(0.85) .multilineTextAlignment(.center) } } - .frame(maxWidth: .infinity) .padding(8) + .frame(maxWidth: .infinity) + .frame(height: Self.optionCardHeight) .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) - .stroke(isSelected ? .primary500 : .beige600, lineWidth: isSelected ? 1.5 : 1) + .stroke(isSelected ? .beige700 : .beige500, lineWidth: 1) ) .opacity(isSelected ? 1.0 : 0.88) } .buttonStyle(.plain) } - private func avatarView(_ philosopher: PhilosopherAvatar) -> some View { - Image(asset: philosopher.imageAsset) + private func avatarView(imageURL: String) -> some View { + KFImage(URL(string: imageURL)) + .placeholder { + SkeletonView() + .frame(width: 28, height: 20) + } .resizable() .scaledToFit() + .frame(width: 28, height: 20) .frame(width: 40, height: 40) .background(.beige600, in: Circle()) } @@ -254,7 +315,7 @@ extension PreVoteView { CustomButton( action: { send(.primaryButtonTapped) }, title: "사전 투표하기", - config: CustomButtonConfig.primary(.large, height: 52), + config: CustomButtonConfig.primary(.large, height: Self.ctaHeight), isEnable: store.isPrimaryButtonEnabled ) } @@ -262,7 +323,7 @@ extension PreVoteView { #Preview { PreVoteView( - store: Store(initialState: PreVoteFeature.State()) { + store: Store(initialState: PreVoteFeature.State(battle: .mock)) { PreVoteFeature() } ) diff --git a/Projects/Presentation/Chat/Tests/Sources/ChatTests.swift b/Projects/Presentation/Chat/Tests/Sources/ChatTests.swift new file mode 100644 index 0000000..206e298 --- /dev/null +++ b/Projects/Presentation/Chat/Tests/Sources/ChatTests.swift @@ -0,0 +1,24 @@ +// +// ChatTests.swift +// Presentation.ChatTests +// +// Created by Roy on 2026-05-21. +// + +@testable import Chat +import Testing + +struct ChatTests { + @Test + func chatExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func chatLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } +} diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift index 157e580..7490054 100644 --- a/Projects/Presentation/Home/Project.swift +++ b/Projects/Presentation/Home/Project.swift @@ -15,6 +15,7 @@ let project = Project.makeAppModule( .SPM.kingfisher, .Domain(implements: .UseCase), .Shared(implements: .DesignSystem), + .Presentation(implements: .Chat) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 61bee05..7feaaeb 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -7,6 +7,7 @@ import Foundation +import Chat import ComposableArchitecture import TCAFlow @@ -60,12 +61,11 @@ extension HomeCoordinator { action: IndexedRouterActionOf ) -> Effect { switch action { - case .routeAction(_, action: .home(.delegate(.presentPreVote))): - state.routes.push(.preVote(.init())) + case let .routeAction(_, action: .home(.delegate(.presentPreVote(battleId)))): + state.routes.push(.chat(.init(battleId: battleId))) return .none - case .routeAction(_, action: .preVote(.delegate(.dismiss))), - .routeAction(_, action: .preVote(.delegate(.submit))): + case .routeAction(_, action: .chat(.delegate(.dismiss))): return .send(.view(.backAction)) default: @@ -88,12 +88,15 @@ extension HomeCoordinator { } } +// swiftformat:disable extensionAccessControl extension HomeCoordinator { @Reducer public enum HomeScreen { case home(HomeFeature) - case preVote(PreVoteFeature) + case chat(ChatCoordinator) } } +// swiftformat:enable extensionAccessControl + extension HomeCoordinator.HomeScreen.State: Equatable {} diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift index a109a6d..6b728fb 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +import Chat import ComposableArchitecture import TCAFlow @@ -24,8 +25,8 @@ public struct HomeCoordinatorView: View { switch screen.case { case let .home(homeStore): HomeView(store: homeStore) - case let .preVote(preVoteStore): - PreVoteView(store: preVoteStore) + case let .chat(chatStore): + ChatCoordinatorView(store: chatStore) .toolbar(.hidden, for: .tabBar) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift index efd0e7c..6c7edfc 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift @@ -31,7 +31,7 @@ struct BestBattleCardView: View { .lineLimit(2) HStack(spacing: 8) { ForEach(battle.tags) { tag in - Text(tag.name) + Text("#\(tag.name)") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.neutral300) } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift index 7ebf9b8..2dc7e93 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift @@ -157,7 +157,7 @@ struct HeroCardView: View { HStack(spacing: 4) { ForEach(hero.tags) { tag in - Text(tag.name) + Text("#\(tag.name)") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.neutral200) } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift index 30c78ec..4093ccf 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift @@ -21,7 +21,7 @@ struct HotBattleCardView: View { thumbnail VStack(alignment: .leading, spacing: 6) { if let tag = battle.tags.first { - Text(tag.name) + Text("#\(tag.name)") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.primary500) } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift index d7e10fc..3220d34 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift @@ -51,7 +51,7 @@ extension NewBattleCardView { private var metaRow: some View { HStack(spacing: 10) { if let tag = battle.tags.first { - Text(tag.name) + Text("#\(tag.name)") .pretendardFont(family: .SemiBold, size: 12) .foregroundStyle(.primary500) .padding(.horizontal, 6) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift index 9e0a202..6d3b581 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift @@ -92,13 +92,18 @@ struct VoteCardView: View { } /// 빈칸: 선택 전엔 빈 placeholder, 선택 후엔 선택된 옵션 텍스트 표시. + /// 선택된 라벨 길이에 맞춰 가변 폭 — 글자가 잘리지 않도록 horizontal padding 만 두고 + /// 최소 폭을 placeholder(52pt) 와 동일하게 유지한다. @ViewBuilder private func answerSlot() -> some View { if let label = selectedLabel { Text(label) .pretendardFont(family: .SemiBold, size: 15) .foregroundStyle(.primary500) - .frame(width: 52, height: 24) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 8) + .frame(minWidth: 52, minHeight: 24) .background(.beige200, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 2aea739..366fa64 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -29,16 +29,26 @@ public struct HomeView: View { HomeSkeletonView() } else { VStack(spacing: 32) { - HeroCarouselView( - heroes: store.heroes, - currentIndex: $store.heroIndex, - onTap: { send(.heroTapped($0)) } - ) + if !store.heroes.isEmpty { + HeroCarouselView( + heroes: store.heroes, + currentIndex: $store.heroIndex, + onTap: { send(.heroTapped($0)) } + ) + } - hotBattlesSection() - bestBattlesSection() - todayPickeSection() - newBattlesSection() + if !store.hotBattles.isEmpty { + hotBattlesSection() + } + if !store.bestBattles.isEmpty { + bestBattlesSection() + } + if !store.quizzes.isEmpty || !store.votes.isEmpty { + todayPickeSection() + } + if !store.newBattles.isEmpty { + newBattlesSection() + } } .padding(.bottom, 24) } @@ -114,7 +124,7 @@ extension HomeView { if let vote = store.currentVote { VoteCardView(question: vote) .contentShape(Rectangle()) - .onTapGesture { } + .onTapGesture {} } } .padding(.horizontal, 16) diff --git a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift deleted file mode 100644 index 9a395e6..0000000 --- a/Projects/Presentation/Home/Sources/Vote/Reducer/PreVoteFeature.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// 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 deleted file mode 100644 index 2a6c4ea..0000000 --- a/Projects/Presentation/Home/Sources/Vote/View/Components/PreVoteSkeletonView.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// 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/Shared/DesignSystem/Project.swift b/Projects/Shared/DesignSystem/Project.swift index 9363922..ade50b5 100644 --- a/Projects/Shared/DesignSystem/Project.swift +++ b/Projects/Shared/DesignSystem/Project.swift @@ -10,6 +10,7 @@ let project = Project.makeModule( product: .staticFramework, settings: .settings(), dependencies: [ + .SPM.composableArchitecture, .Shared(implements: .ThirdParty) ], sources: ["Sources/**"], diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift new file mode 100644 index 0000000..c0a8a5a --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertModifiers.swift @@ -0,0 +1,31 @@ +// +// CustomAlertModifiers.swift +// DesignSystem +// + +import ComposableArchitecture +import SwiftUI + +public extension View { + func customAlert( + _ store: Binding, CustomAlertAction>?> + ) -> some View { + overlay { + if let alertStore = store.wrappedValue { + let alertState = alertStore.withState { $0 } + CustomConfirmationPopup( + title: alertState.title, + message: alertState.message, + confirmTitle: alertState.confirmTitle, + cancelTitle: alertState.cancelTitle, + isDestructive: alertState.isDestructive, + style: alertState.style, + onConfirm: { alertStore.send(.confirmTapped) }, + onCancel: { alertStore.send(.cancelTapped) } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.easeInOut(duration: 0.3), value: alertState.title.isEmpty == false) + } + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift new file mode 100644 index 0000000..11169e2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift @@ -0,0 +1,81 @@ +// +// CustomAlertState.swift +// DesignSystem +// + +import ComposableArchitecture +import SwiftUI + +@ObservableState +public struct CustomAlertState: Equatable { + public let title: String + public let message: String + public let confirmTitle: String + public let cancelTitle: String + public let isDestructive: Bool + public let style: CustomAlertStyle + + public init( + title: String, + message: String = "", + confirmTitle: String = "확인", + cancelTitle: String = "취소", + isDestructive: Bool = false, + style: CustomAlertStyle = .confirmation + ) { + self.title = title + self.message = message + self.confirmTitle = confirmTitle + self.cancelTitle = cancelTitle + self.isDestructive = isDestructive + self.style = style + } +} + +public enum CustomAlertStyle: Equatable { + case confirmation + case finalVote +} + +@CasePathable +public enum CustomAlertAction: Equatable { + case confirmTapped + case cancelTapped +} + +@Reducer +public struct CustomConfirmAlert { + public init() {} + + public var body: some Reducer, CustomAlertAction> { + EmptyReducer() + } +} + +public extension CustomAlertState where Action == CustomAlertAction { + static func alert( + title: String, + message: String = "", + confirmTitle: String = "확인", + cancelTitle: String = "취소", + isDestructive: Bool = false + ) -> CustomAlertState { + CustomAlertState( + title: title, + message: message, + confirmTitle: confirmTitle, + cancelTitle: cancelTitle, + isDestructive: isDestructive, + style: .confirmation + ) + } + + static func finalVote() -> CustomAlertState { + CustomAlertState( + title: "최종투표하고 투표 결과를 보시겠습니까?", + confirmTitle: "최종투표하기", + cancelTitle: "다시 들어볼래요", + style: .finalVote + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift new file mode 100644 index 0000000..e9f438f --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -0,0 +1,168 @@ +// +// CustomConfirmationPopupView.swift +// DesignSystem +// + +import SwiftUI + +struct CustomConfirmationPopup: View { + private let title: String + private let message: String + private let confirmTitle: String + private let cancelTitle: String + private let isDestructive: Bool + private let style: CustomAlertStyle + private let onConfirm: () -> Void + private let onCancel: () -> Void + + @State private var isContentVisible = false + + init( + title: String, + message: String, + confirmTitle: String, + cancelTitle: String, + isDestructive: Bool, + style: CustomAlertStyle, + onConfirm: @escaping () -> Void, + onCancel: @escaping () -> Void + ) { + self.title = title + self.message = message + self.confirmTitle = confirmTitle + self.cancelTitle = cancelTitle + self.isDestructive = isDestructive + self.style = style + self.onConfirm = onConfirm + self.onCancel = onCancel + } + + var body: some View { + ZStack { + Color.black + .opacity(isContentVisible ? 0.6 : 0) + .ignoresSafeArea() + .onTapGesture(perform: onCancel) + + popupContent + .padding(.horizontal, 20) + .offset(y: isContentVisible ? 0 : 120) + .opacity(isContentVisible ? 1 : 0) + } + .onAppear { + withAnimation(.easeInOut(duration: 0.3)) { + isContentVisible = true + } + } + } + + @ViewBuilder + private var popupContent: some View { + switch style { + case .confirmation: + confirmationContent + case .finalVote: + finalVoteContent + } + } + + private var confirmationContent: some View { + VStack(spacing: 24) { + VStack(spacing: 8) { + Text(title) + .pretendardFont(family: .Bold, size: 18) + .foregroundStyle(.neutral800) + .multilineTextAlignment(.center) + + if !message.isEmpty { + Text(message) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .multilineTextAlignment(.center) + } + } + + HStack(spacing: 8) { + if !cancelTitle.isEmpty { + Button(action: onCancel) { + Text(cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral500) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + } + + Button(action: onConfirm) { + Text(confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.beige50) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + isDestructive ? Color.errorDefault : Color.primary500, + in: RoundedRectangle(cornerRadius: 2) + ) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 28) + .padding(.horizontal, 20) + .frame(width: 320) + .background(ComponentToken.Popup.background, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(ComponentToken.Popup.border, lineWidth: 1) + ) + .onTapGesture {} + } + + private var finalVoteContent: some View { + VStack(spacing: 16) { + Text(title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral900) + .lineSpacing(14 * 0.4) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.vertical, 20) + + HStack(spacing: 10) { + Button(action: onCancel) { + Text(cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary500) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onConfirm) { + Text(confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } + } + .frame(width: 313) + .background(.beige500, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.primary500, lineWidth: 1.5) + ) + .opacity(0.9) + .onTapGesture {} + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift b/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift new file mode 100644 index 0000000..1962b8c --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/AudioPlayer/AudioPlayerControlView.swift @@ -0,0 +1,100 @@ +// +// AudioPlayerControlView.swift +// DesignSystem +// +// .pen `Group 26` (재생바 컨트롤 3 버튼) 공통 컴포넌트. +// 채팅방 / 배틀 상세 등 오디오 플레이백이 필요한 화면에서 재사용. +// + +import SwiftUI + +public struct AudioPlayerControlView: View { + @Binding private var isPlaying: Bool + private let onBackward: () -> Void + private let onTogglePlay: () -> Void + private let onForward: () -> Void + + public init( + isPlaying: Binding, + onBackward: @escaping () -> Void, + onTogglePlay: @escaping () -> Void, + onForward: @escaping () -> Void + ) { + _isPlaying = isPlaying + self.onBackward = onBackward + self.onTogglePlay = onTogglePlay + self.onForward = onForward + } + + public var body: some View { + HStack(alignment: .top, spacing: 32) { + backwardButton() + playButton() + forwardButton() + } + .padding(.horizontal, 4) + } + + @ViewBuilder + private func backwardButton() -> some View { + Button(action: onBackward) { + controlColumn( + systemImage: "backward.end.fill", + iconColor: .gray500, + iconSize: CGSize(width: 24, height: 55), + caption: "15초" + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func playButton() -> some View { + Button(action: onTogglePlay) { + controlColumn( + systemImage: isPlaying ? "pause.fill" : "play.fill", + iconColor: .gray500, + iconSize: CGSize(width: 55, height: 55), + caption: nil + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func forwardButton() -> some View { + Button(action: onForward) { + controlColumn( + systemImage: "forward.end.fill", + iconColor: .gray500, + iconSize: CGSize(width: 24, height: 55), + caption: "15초" + ) + } + .buttonStyle(.plain) + } + + /// 세 버튼이 동일한 baseline 으로 정렬되도록 VStack 구조 + caption 자리를 항상 확보한다. + /// `iconSize` 는 hit 영역 사이즈 — center play 는 55x55, 양옆 seek 는 24x55. + @ViewBuilder + private func controlColumn( + systemImage: String, + iconColor: Color, + iconSize: CGSize, + caption: String? + ) -> some View { + VStack(spacing: 4) { + Image(systemName: systemImage) + .resizable() + .scaledToFit() + .foregroundStyle(iconColor) + .frame(width: iconSize.width, height: iconSize.height) + .contentShape(Rectangle()) + + Text(caption ?? " ") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + .opacity(caption == nil ? 0 : 1) + } + } +}