diff --git a/.swiftformat b/.swiftformat index 385d66f3..82430650 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,3 +1,4 @@ --indent 2 --swiftversion 6.0 --maxwidth 120 +--disable redundantReturn diff --git a/AGENTS.md b/AGENTS.md index 119f4dbc..4bcfbdd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,12 +148,14 @@ public var body: some View { - 한 메서드 안에서 다시 큰 블록이 생기면 더 작게 쪼개기 (재귀 적용) - 공통 컴포넌트는 별도 파일 (`Components/*.swift`) 로 추출 -#### 🧱 `@ViewBuilder` 함수 vs `var` — 자식 개수 / 분기 유무로 결정 +#### 🧱 `@ViewBuilder` + `private func` — 모든 sub-view 는 함수 형태 (필수) -분리한 sub-view 의 선언 형태는 **자식 개수와 분기 유무** 로만 정한다. +분리한 sub-view 는 **자식 개수 / 분기 유무와 상관없이 무조건 `@ViewBuilder` + `private func` 형태** 로 통일한다. + +`private var ...: some View` 형태는 **금지** — 모든 sub-view 는 호출부에서 일관되게 `()` 호출이 보이도록 함수 형태로만 작성한다. ```swift -// ✅ 다중 자식을 감싸거나 if/else · switch 분기가 있으면 `@ViewBuilder` + 함수 +// ✅ 다중 자식 / 분기 / 반복 @ViewBuilder private func hotBattlesSection() -> some View { VStack(alignment: .leading, spacing: 12) { @@ -175,8 +177,9 @@ private func thumbnail(url: URL?) -> some View { } } -// ✅ 단일 뷰만 반환하면 `private var` 형태 -private var primaryButton: some View { +// ✅ 단일 뷰여도 함수 형태로 +@ViewBuilder +private func primaryButton() -> some View { CustomButton( action: { send(.primaryButtonTapped) }, title: "사전 투표하기", @@ -185,17 +188,55 @@ private var primaryButton: some View { ) } -// ❌ 금지 — VStack 으로 자식 여러 개 감싸는데 var 만 쓰는 경우 (분기 / 동적 children 추가 시 깨짐) -private var section: some View { - VStack { ... } // → @ViewBuilder + func 으로 가야 안전 +// ❌ 금지 — sub-view 를 var 로 선언 +private var primaryButton: some View { ... } +private var section: some View { VStack { ... } } +``` + +규칙: +- **모든 sub-view = `@ViewBuilder private func name() -> some View`** (단일 뷰 / 다중 자식 / 분기 무관) +- `private var ...: some View` 패턴 금지 — 호출부에서 `name` vs `name()` 형태 혼재되는 것을 방지 +- `body` 만 `var body: some View` 유지 (View 프로토콜 요구사항) +- body 안에서 호출은 항상 `()` 가 붙은 함수 형태로 — 가독성 통일 + +#### 📏 함수 시그니처 — 파라미터 2개 이상이면 멀티 라인 (필수) + +함수 파라미터가 **2개 이상이면 각 파라미터를 새 줄에** 풀어 쓴다. 인라인 한 줄 시그니처는 1-파라미터 이하에서만 허용. + +```swift +// ✅ 파라미터 2개 이상 — 멀티 라인 +@ViewBuilder +private func voteSide( + _ option: VoteOptionSummary, + alignment: HorizontalAlignment +) -> some View { + VStack(alignment: alignment, spacing: 6) { ... } } + +@ViewBuilder +private func optionButton( + _ choice: Choice, + label: String, + desc: String, + isCorrect: Bool +) -> some View { ... } + +// ✅ 파라미터 0~1개 — 한 줄 OK +@ViewBuilder +private func navigationBar() -> some View { ... } + +@ViewBuilder +private func filterButton(_ filter: CommentFilter) -> some View { ... } + +// ❌ 금지 — 파라미터 2개 이상을 한 줄로 +private func voteSide(_ option: VoteOptionSummary, alignment: HorizontalAlignment) -> some View +private func resultBadge(isSelected: Bool, isCorrect: Bool) -> some View ``` 규칙: -- **`@ViewBuilder` + `private func`** : VStack/HStack/ZStack 등으로 **자식 ≥ 2개** 를 감싸거나 `if` / `switch` / `ForEach` 같은 분기·반복이 있을 때 -- **`private var ...: some View`** : **단일 뷰** 1개만 반환할 때 (단순 wrapping · CTA 버튼 · 단일 Image 등) -- body 안에서 호출하는 sub-view 가 인자가 필요하면 함수, 없으면 var 가 우선 — 기준은 "자식 수 / 분기 유무" 가 먼저 -- 레퍼런스: `HomeView.hotBattlesSection`, `PreVoteView.primaryButton`, `HeroCardView.thumbnail` +- View 함수 / Reducer 헬퍼 / 일반 메서드 / init 모두 동일 — 파라미터 ≥ 2 → 멀티 라인 +- 호출부 (call site) 도 동일 — `.init(a: x, b: y, c: z)` 처럼 인자 ≥ 2 면 멀티 라인 권장 (이미 대다수 코드 이 패턴) +- 닫는 `)` 와 `-> some View` 는 시그니처 끝줄에 함께 #### 🔤 폰트 — `.font(.system(...))` 금지, Pretendard 토큰 사용 @@ -368,6 +409,95 @@ public init( 레퍼런스: `HomeFeature.State`, attendance `ProfileFeature.State` +#### 📦 Feature 화면 모델 / Item / Filter / Sort 는 Entity 모듈에 정의 (필수) + +`XxxFeature.swift` 안에 `XxxItem` · `XxxSummary` · `XxxFilter` · `XxxSort` 같은 **화면용 모델 / 분기 enum** 을 직접 선언하지 않는다. 모두 `Domain/Entity` 모듈에 정의하고 Feature/View 에서는 `import Entity` 로 가져다 쓴다. + +```swift +// ✅ 올바른 패턴 — 화면 모델은 Entity 모듈에 정의 +// Projects/Domain/Entity/Sources/Home/Comment.swift +public struct CommentItem: Equatable, Identifiable { ... } +public enum CommentFilter: String, CaseIterable, Equatable { ... } +public enum CommentSort: String, CaseIterable, Equatable { ... } +public struct VoteSummary: Equatable { ... } + +// Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +import Entity + +@Reducer +public struct CommentFeature { + @ObservableState + public struct State: Equatable { + public var comments: [CommentItem] = [] + public var selectedFilter: CommentFilter = .all + public var selectedSort: CommentSort = .popular + public var voteSummary: VoteSummary = .empty + } +} + +// ❌ 금지 — Feature 파일 안에 모델을 같이 선언 +// CommentFeature.swift +public struct CommentItem: Equatable, Identifiable { ... } // ← Entity 로 이동 +public enum CommentFilter: ... { ... } // ← Entity 로 이동 +public struct VoteSummary: Equatable { ... } // ← Entity 로 이동 +``` + +규칙: +- **모든 화면 모델 (struct/enum)** 은 `Projects/Domain/Entity/Sources/<도메인>/` 아래에 둔다 (`Home/Comment.swift`, `Home/Battle.swift` 등) +- Feature 안에는 `State` / `Action` / `Reducer` / `CancelID` 같은 **TCA 컴포넌트만** 둔다 +- UI 분기용 enum (`CommentFilter`, `CommentSort`) 도 도메인 모델로 취급해 Entity 에 둔다 — 같은 도메인의 여러 화면에서 재사용 가능 +- 서버 응답 매핑 init (`init(item: BattlePerspective, order: Int)`) · 정적 mocks · `.empty` 팩토리도 모두 Entity 쪽에서 정의 +- 의존성 방향 유지: `Presentation → Domain ← Data` — Entity 는 SwiftUI/TCA/Network 비의존 (Foundation 만) + +레퍼런스: `Entity/Sources/Home/Comment.swift` (CommentItem, CommentFilter, CommentSort, CommentReplyItem, VoteSummary) + +#### 🗂️ Feature 파일 — Reducer 관련 코드만 (필수) + +`XxxFeature.swift` 안에는 **TCA Reducer 구성 요소만** 둔다. presentation 전용 helper 라도 모델/타입은 nested 로 두지 말고 **같은 모듈의 `Model/` 폴더에 별도 파일**로 분리한다. + +```swift +// ✅ Feature 파일은 reducer 구성 요소만 +@Reducer +public struct PreVoteFeature { + @ObservableState + public struct State: Equatable { + public var shareItem: ShareItem? // ← top-level 타입 참조 + // … + } + + public enum Action: ViewAction, BindableAction { … } + public enum View { case shareTapped(snapshot: Data?) … } + public enum AsyncAction: Equatable { case prepareShare(ShareContent) … } + public enum InnerAction: Equatable { case sharePrepared(ShareItem) … } + nonisolated enum CancelID: Hashable { … } + + @Dependency(\.battleUseCase) private var battleUseCase + public var body: some Reducer { … } +} + +// Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift +public struct ShareItem: Equatable, Identifiable { … } + +// Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift +public struct ShareContent: Equatable { … } + +// ❌ 금지 — Feature 안에 nested 로 같이 선언 +@Reducer +public struct PreVoteFeature { + public struct ShareItem: Equatable, Identifiable { … } // ← Model/ 로 분리 + public struct ShareContent: Equatable { … } // ← Model/ 로 분리 +} +``` + +규칙: +- Feature 파일 (`XxxFeature.swift`) 에는 **`State` / `Action` / `View` / `AsyncAction` / `InnerAction` / `ScopeAction` / `DelegateAction` / `CancelID` / `body` / `@Dependency` 만** 둔다 +- presentation 전용 모델 (`ShareItem`, `ShareContent`, sheet item 등) 은 **같은 모듈의 `Model/` 폴더에 top-level public struct/enum** 으로 분리 +- 도메인 모델은 [위 규칙](#-feature-화면-모델--item--filter--sort-는-entity-모듈에-정의-필수) 대로 `Domain/Entity` 모듈로 +- presentation 모델에 포함되는 헬퍼 (텍스트 빌드 같은 표현 로직) 는 모델 파일 안에 computed property / method 로 함께 둔다 — reducer 가 책임지지 않는다 +- Reducer 안의 helper 함수 (`makeBattle(from:)`, `handleViewAction(state:action:)` 등) 는 Feature 파일에 그대로 둬도 OK — reducer 관련이라는 점이 명확하면 분리 강제하지 않음 +- 파일 위치: `Projects/Presentation/<모듈>/Sources/<도메인>/Model/.swift` +- 레퍼런스: `Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift`, `Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift` + #### ⚡ AsyncAction — `Result { try await }` + `mapError` + 단일 `Response` Inner 액션 `do/catch + 별도 Loaded / Failed 액션` 분리하지 말고, `Result` 로 감싸서 단일 `xxxResponse(Result)` Inner 액션으로 보낸다. State 캡쳐는 `[키 = state.xxx]` 형태. @@ -416,6 +546,73 @@ return .run { send in - `.cancellable(id: CancelID.xxx, cancelInFlight: true)` 로 중복 호출 방지 - 레퍼런스: `AuthUseCaseImpl.withDraw` / `HomeFeature.fetchHome` +#### 🧩 UseCase 강제 — Feature 는 Repository 직접 의존 금지 (필수) + +Clean Architecture 의존 방향(`Presentation → Domain ← Data`) 을 지키기 위해 **Feature/Reducer 는 절대로 `@Dependency(\.xxxRepository)` 를 직접 잡지 않는다**. 모든 외부 IO 는 `XxxUseCase` 프로토콜을 통해서만 호출한다. + +```swift +// ✅ 올바른 패턴 — Feature 는 UseCase 만 의존 +@Reducer +public struct PreVoteFeature { + @Dependency(\.battleUseCase) private var battleUseCase + @Dependency(\.perspectiveUseCase) private var perspectiveUseCase + + // ... .run { [useCase = battleUseCase] send in + // try await useCase.fetchBattle(battleId: battleId) + // } +} + +// UseCase Impl — Projects/Domain/UseCase/Sources/<도메인>/UseCase.swift +// 별도 protocol 만들지 않고 기존 Interface 를 그대로 채택한다 (Attendance_iOS 패턴). +public struct BattleUseCaseImpl: BattleInterface { + @Dependency(\.battleRepository) private var battleRepository + + public init() {} + + public func fetchBattle(battleId: Int) async throws -> BattleDetail { + try await battleRepository.fetchBattle(battleId: battleId) + } + // ... 나머지 메서드도 동일하게 repository 로 단순 위임 +} + +extension BattleUseCaseImpl: DependencyKey { + public static var liveValue = BattleUseCaseImpl() + public static var testValue = BattleUseCaseImpl() + public static var previewValue = BattleUseCaseImpl() +} + +public extension DependencyValues { + var battleUseCase: BattleUseCaseImpl { + get { self[BattleUseCaseImpl.self] } + set { self[BattleUseCaseImpl.self] = newValue } + } +} + +// ❌ 금지 — Feature 가 Repository 를 직접 잡음 +@Reducer +public struct PreVoteFeature { + @Dependency(\.battleRepository) private var battleRepository // ← UseCase 거치게 + @Dependency(\.perspectiveRepository) private var perspectiveRepository +} + +// ❌ 금지 — UseCase 용 새 protocol 을 별도로 만들기 (Interface 중복 정의) +public protocol BattleUseCase: Sendable { ... } +public struct BattleUseCaseImpl: BattleUseCase { ... } +``` + +규칙: +- **Feature/Reducer**: `@Dependency(\.UseCase)` 만 사용 — Repository 키 사용 금지 +- **UseCase Impl**: 기존 `XxxInterface` 를 **그대로 채택** (별도 `XxxUseCase` protocol 만들지 않음 — Attendance_iOS `AuthUseCaseImpl: AuthInterface` 패턴) +- **Repository 잡는 방식**: Impl 안에서 `@Dependency(\.xxxRepository) private var xxxRepository` 로 직접 잡고, `public init() {}` 만 노출 (Attendance 패턴) +- **DependencyKey 채택**: Impl 자체에 `extension XxxUseCaseImpl: DependencyKey { liveValue / testValue / previewValue }` 를 모두 정의 — 별도 `enum XxxUseCaseKey` 만들지 않음 +- **liveValue / testValue / previewValue 모두 명시**: 세 값 모두 `XxxUseCaseImpl()` 로 동일하게 둠 (Attendance 패턴 — 테스트/프리뷰에서 별도 mock 이 필요해지면 그때 교체) +- **DependencyValues 키 이름**: `UseCase` (`battleUseCase`, `homeUseCase`, `commentUseCase`, `perspectiveUseCase`) — 키 타입은 `XxxUseCaseImpl` (Interface 가 아닌 Impl) +- **Repository 키 (`battleRepository`, `homeRepository`, …)** 는 UseCase Impl 안에서만 사용. Presentation 에서는 호출 금지 +- 동일 도메인의 모든 IO 를 하나의 UseCase 파일에 모음 (multi-method UseCase). 액션 1개짜리 도메인이면 Attendance `FetchMyAttendancesUseCase` 처럼 별도 protocol 1개 + Impl 1개 형태도 OK +- 새 IO 가 추가되면: 1) Repository 메서드 추가 → 2) UseCase Impl 에 위임 메서드 추가 → 3) Feature 에서 UseCase 호출 + +레퍼런스: `BattleUseCaseImpl`, `HomeUseCaseImpl`, `CommentUseCaseImpl`, `PerspectiveUseCaseImpl`, Attendance_iOS `AuthUseCaseImpl` + #### 🔌 RepositoryImpl — Provider 선언 패턴 Repository 구현체의 `MoyaProvider` 는 `let` 으로 직접 선언하고, init 기본값으로 `.default` / `.authorized` 팩토리를 그대로 사용한다. `Optional + nil 합치기`나 `MoyaProviderPool` 인다이렉션 금지. @@ -625,6 +822,22 @@ public var method: Moya.Method { - 커밋 메시지에 `Co-Authored-By: Claude ...` 등의 자동 서명 라인을 절대 추가하지 않음 - OMX 훅 검증을 위해 커밋 명령에는 `Co-authored-by: OmX ` trailer를 포함하되, 최종 커밋 메시지에서는 제거 +### 📦 커밋 단위 / 크기 규칙 — 30 파일 이상이면 끊어서 (필수) + +한 번에 너무 많은 파일을 묶으면 리뷰가 불가능하고 회귀 발생 시 bisect 가 어렵다. 변경된 파일 수가 많으면 **의미 단위로 끊어서 여러 커밋**으로 나눈다. + +규칙: +- 한 커밋의 **변경 파일 수가 30개를 초과하면 반드시 분할** +- 분할 기준은 **모듈 / 도메인 / 변경 종류** 가 우선: + - 예) `API + Service + RepositoryImpl` 같이 도메인 레이어 한 줄기 → 1 커밋 + - 예) `UseCase 신규 정의` → 1 커밋 + - 예) `Feature 들의 @Dependency 교체` → 1 커밋 + - 예) `View / Reducer UI 변경` → 1 커밋 + - 예) `AGENTS.md / .swiftformat 같은 규칙 / 설정` → 1 커밋 +- 분할 후 각 커밋은 **단독으로 빌드 가능**해야 함 (의존하는 다른 커밋이 같은 PR 안에 있으면 OK, 다른 PR 에 있으면 분할 순서 조정) +- 작업 시작 전에 변경 범위를 보고 **30 파일이 넘을 것 같으면 미리 단계로 쪼개기** +- 단순 포맷터 / 자동 변환 (예: 전역 시그니처 멀티라인) 은 30 파일 넘어도 1 커밋 OK — 단 커밋 메시지에 "전역 자동 변환" 명시 + ### 🧭 TCAFlow 네비게이션 (`docs/agent/tcaflow-navigation.md`) - @FlowCoordinator 패턴 - 기본 네비게이션 동작 (Push, Present, Dismiss) diff --git a/ONBOARDING.md b/ONBOARDING.md new file mode 100644 index 00000000..44a205fd --- /dev/null +++ b/ONBOARDING.md @@ -0,0 +1,63 @@ +# Welcome to Picke iOS + +## How We Use Claude + +Based on Roy's usage over the last 30 days: + +Work Type Breakdown: + Improve Quality ████████░░░░░░░░░░░░ 38% + Build Feature ██████░░░░░░░░░░░░░░ 31% + Debug Fix ███░░░░░░░░░░░░░░░░░ 15% + Plan Design ██░░░░░░░░░░░░░░░░░░ 8% + Write Docs ██░░░░░░░░░░░░░░░░░░ 8% + +Top Skills & Commands: + /plugin ████████████████████ 3x/month + /figma:figma-use ███████░░░░░░░░░░░░░ 1x/month + +Top MCP Servers: + plugin_figma_figma ████████████████████ 15 calls + +## Your Setup Checklist + +### Codebases +- [ ] picke-ios — https://github.com/swyp-find/picke-ios (메인 iOS 앱, Tuist + TCA + Clean Architecture) +- [ ] design-tokens — https://github.com/SWYP-Find/design-tokens (Tokens Studio JSON, 자동 코드젠 소스) +- [ ] Attendance_iOS — 사내 다른 iOS 레퍼런스 (UseCase / AGENTS.md 패턴 참고용) + +### MCP Servers to Activate +- [ ] plugin_figma_figma — Figma 디자인 파일을 Claude 안에서 직접 조회/조작 (변수 / 컴포넌트 / 스냅샷). Claude Code 플러그인 마켓에서 `figma@claude-plugins-official` 설치 후 워크스페이스 OAuth 연결 + +### Skills to Know About +- /plugin — Claude Code 플러그인 설치/관리. Figma 등 MCP 추가 시 진입점 +- /figma:figma-use — Figma MCP 의 `use_figma` 툴을 안전하게 호출하기 위한 prerequisite skill. 디자인 파일에서 변수/컴포넌트 만들거나 노드 조작 전 반드시 먼저 실행 + +## Team Tips + +_TODO_ + +## Get Started + +_TODO_ + + diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index b838c0ad..bd108ac0 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -29,6 +29,7 @@ public extension ModulePath { public static let name: String = "Presentation" + case Hifi } } diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 37bb5fe0..6678ba1f 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -37,6 +37,8 @@ public final class AppDIManager: Sendable { .register { AuthRepositoryImpl() as AuthInterface } .register { HomeRepositoryImpl() as HomeInterface } .register { BattleRepositoryImpl() as BattleInterface } + .register { CommentRepositoryImpl() as CommentInterface } + .register { PerspectiveRepositoryImpl() as PerspectiveInterface } .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } // .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index 1e1cd6e7..a8fc4724 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -207,7 +207,10 @@ public struct AppReducer: Sendable { } // 🎯 PFW 철학: 단순하고 조합 가능한 상태 검증 - private func isValidAction(_ action: ScopeAction, for state: State) -> Bool { + private func isValidAction( + _ action: ScopeAction, + for state: State + ) -> Bool { switch (action, state) { case (.auth, .auth), (.splash, .splash), (.mainTab, .mainTab): return true diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift index 5e095655..31261ddf 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -15,25 +15,31 @@ public enum PieckeDomain { case home case poll case battle + case comment + case perspective } extension PieckeDomain: DomainType { public var baseURLString: String { - return BaseAPI.base.apiDescription + BaseAPI.base.apiDescription } public var url: String { switch self { case .auth: - return "api/v1/auth/" + "api/v1/auth/" case .profile: - return"api/v1/me/" + "api/v1/me/" case .home: - return"api/v1/home" + "api/v1/home" case .poll: - return "api/v1/poll" + "api/v1/poll" case .battle: - return "api/v1/battles/" + "api/v1/battles/" + case .comment: + "api/v1/comments/" + case .perspective: + "api/v1/perspectives/" } } } diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index 35d34d54..849ddae3 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -8,16 +8,31 @@ import Foundation public enum BattleAPI { case detail(battleId: Int) case preVote(battleId: Int) + case postVote(battleId: Int) case scenario(battleId: Int) + case voteStats(battleId: Int) + case perspectives(battleId: Int) + case myPerspective(battleId: Int) + case recommendations(battleId: Int) public var description: String { switch self { case let .detail(battleId): - "\(battleId)" + return "\(battleId)" case let .preVote(battleId): - "\(battleId)/votes/pre" + return "\(battleId)/votes/pre" + case let .postVote(battleId): + return "\(battleId)/votes/post" case let .scenario(battleId): - "\(battleId)/scenario" + return "\(battleId)/scenario" + case let .voteStats(battleId): + return "\(battleId)/vote-stats" + case let .perspectives(battleId): + return "\(battleId)/perspectives" + case let .myPerspective(battleId): + return "\(battleId)/perspectives/me" + case let .recommendations(battleId): + return "\(battleId)/recommendations/interesting" } } } diff --git a/Projects/Data/API/Sources/Comment/CommentAPI.swift b/Projects/Data/API/Sources/Comment/CommentAPI.swift new file mode 100644 index 00000000..7cb7d124 --- /dev/null +++ b/Projects/Data/API/Sources/Comment/CommentAPI.swift @@ -0,0 +1,20 @@ +// +// CommentAPI.swift +// API +// + +import Foundation + +public enum CommentAPI { + case like(commentId: Int) + case unlike(commentId: Int) + + public var description: String { + switch self { + case let .like(commentId): + "\(commentId)/likes" + case let .unlike(commentId): + "\(commentId)/likes" + } + } +} diff --git a/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift new file mode 100644 index 00000000..33633982 --- /dev/null +++ b/Projects/Data/API/Sources/Perspective/PerspectiveAPI.swift @@ -0,0 +1,38 @@ +// +// PerspectiveAPI.swift +// API +// + +import Foundation + +public enum PerspectiveAPI { + case detail(perspectiveId: Int) + case listLabeledComments(perspectiveId: Int) + case createComment(perspectiveId: Int) + case updateComment(perspectiveId: Int, commentId: Int) + case deleteComment(perspectiveId: Int, commentId: Int) + case likes(perspectiveId: Int) + case reports(perspectiveId: Int) + case reportComment(perspectiveId: Int, commentId: Int) + + public var description: String { + switch self { + case let .detail(perspectiveId): + return "\(perspectiveId)" + case let .likes(perspectiveId): + return "\(perspectiveId)/likes" + case let .reports(perspectiveId): + return "\(perspectiveId)/reports" + case let .reportComment(perspectiveId, commentId): + return "\(perspectiveId)/comments/\(commentId)/reports" + case let .listLabeledComments(perspectiveId): + return "\(perspectiveId)/comments/labeled" + case let .createComment(perspectiveId): + return "\(perspectiveId)/comments" + case let .updateComment(perspectiveId, commentId): + return "\(perspectiveId)/comments/\(commentId)" + case let .deleteComment(perspectiveId, commentId): + return "\(perspectiveId)/comments/\(commentId)" + } + } +} diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift index d10276cd..66e67cbf 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift @@ -30,7 +30,7 @@ public struct BattleInfoDTO: Decodable { public struct BattleOptionDTO: Decodable { public let optionId: Int - public let label: String + public let label: String? public let title: String public let stance: String public let representative: String diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift new file mode 100644 index 00000000..64b46e64 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattlePerspectiveDataDTO.swift @@ -0,0 +1,47 @@ +// +// BattlePerspectiveDataDTO.swift +// Model +// + +import Foundation + +public struct BattlePerspectivePageDataDTO: Decodable { + public let items: [BattlePerspectiveDTO] + public let nextCursor: String? + public let hasNext: Bool +} + +public struct BattlePerspectiveDTO: Decodable { + public let perspectiveId: Int + public let user: BattlePerspectiveUserDTO + public let option: BattlePerspectiveOptionDTO + public let content: String + public let likeCount: Int + public let commentCount: Int + public let isLiked: Bool? + public let isMyPerspective: Bool? + public let createdAt: String? +} + +public struct BattlePerspectiveUserDTO: Decodable { + public let userTag: String? + public let nickname: String? + public let characterType: String? + public let characterImageUrl: String? +} + +public struct BattlePerspectiveOptionDTO: Decodable { + public let optionId: Int + public let label: String? + public let title: String? + public let stance: String? +} + +public struct CreatePerspectiveDataDTO: Decodable { + public let perspectiveId: Int + public let status: String? + public let createdAt: String? +} + +public typealias BattlePerspectivePageResponseDTO = BaseResponseDTO +public typealias CreatePerspectiveResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift index 1468bed8..e0235122 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleScenarioDataDTO.swift @@ -17,7 +17,7 @@ public struct BattleScenarioDataDTO: Decodable { } public struct ScenarioPhilosopherDTO: Decodable { - public let label: String + public let label: String? public let name: String public let stance: String public let imageUrl: String diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift new file mode 100644 index 00000000..bc7d0369 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleVoteStatsDataDTO.swift @@ -0,0 +1,25 @@ +// +// BattleVoteStatsDataDTO.swift +// Model +// + +import Foundation + +public struct BattleVoteStatsDataDTO: Decodable { + public let options: [BattleVoteStatsOptionDTO] + public let totalCount: Int + public let updatedAt: String? +} + +public struct BattleVoteStatsOptionDTO: Decodable { + public let optionId: Int + public let label: String? + public let title: String? + public let isCorrect: Bool? + public let voteCount: Int + public let ratio: Double + public let stance: String? + public let imageUrl: String? +} + +public typealias BattleVoteStatsResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift new file mode 100644 index 00000000..3a7760ed --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/RecommendedBattleDataDTO.swift @@ -0,0 +1,38 @@ +// +// RecommendedBattleDataDTO.swift +// Model +// + +import Foundation + +public struct RecommendedBattlePageDataDTO: Decodable { + public let items: [RecommendedBattleDTO] + public let nextCursor: String? + public let hasNext: Bool +} + +public struct RecommendedBattleDTO: Decodable { + public let battleId: Int + public let title: String? + public let summary: String? + public let audioDuration: Int? + public let viewCount: Int? + public let tags: [RecommendedBattleTagDTO]? + public let participantsCount: Int? + public let options: [RecommendedBattleOptionDTO]? +} + +public struct RecommendedBattleTagDTO: Decodable { + public let tagId: Int + public let name: String? +} + +public struct RecommendedBattleOptionDTO: Decodable { + public let optionId: Int + public let title: String? + public let stance: String? + public let representative: String? + public let imageUrl: String? +} + +public typealias RecommendedBattlePageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift index b356d464..9dfead44 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift @@ -41,7 +41,7 @@ public extension BattleOptionDTO { func toDomain() -> BattleOption { BattleOption( optionId: optionId, - label: label, + label: label ?? "", title: title, stance: stance, representative: representative, diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift new file mode 100644 index 00000000..45e644c7 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattlePerspectiveDataDTO+.swift @@ -0,0 +1,63 @@ +// +// BattlePerspectiveDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattlePerspectivePageDataDTO { + func toDomain() -> BattlePerspectivePage { + BattlePerspectivePage( + items: items.map { $0.toDomain() }, + nextCursor: nextCursor, + hasNext: hasNext + ) + } +} + +public extension BattlePerspectiveDTO { + func toDomain() -> BattlePerspective { + BattlePerspective( + perspectiveId: perspectiveId, + user: user.toDomain(), + option: option.toDomain(), + content: content, + likeCount: likeCount, + commentCount: commentCount, + isLiked: isLiked ?? false, + isMyPerspective: isMyPerspective ?? false, + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public extension BattlePerspectiveUserDTO { + func toDomain() -> BattlePerspectiveUser { + BattlePerspectiveUser( + userTag: userTag ?? "", + nickname: nickname ?? "익명", + characterType: characterType ?? "", + characterImageUrl: characterImageUrl + ) + } +} + +public extension BattlePerspectiveOptionDTO { + func toDomain() -> BattlePerspectiveOption { + BattlePerspectiveOption( + optionId: optionId, + label: label, + title: title ?? "", + stance: stance ?? "" + ) + } +} diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift index 0f9f1e2d..15981606 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleScenarioDataDTO+.swift @@ -11,7 +11,9 @@ public extension BattleScenarioDataDTO { BattleScenario( battleId: battleId, title: title, - philosophers: philosophers.map { $0.toDomain() }, + philosophers: philosophers.enumerated().map { idx, dto in + dto.toDomain(fallbackLabel: Self.fallbackLabel(for: idx)) + }, isInteractive: isInteractive, startNodeId: startNodeId, recommendedPathKey: RecommendedPathKey(rawValue: recommendedPathKey), @@ -19,11 +21,22 @@ public extension BattleScenarioDataDTO { nodes: nodes.map { $0.toDomain() } ) } + + /// 응답에서 label 이 누락된 경우 사용할 인덱스 기반 폴백 (A/B/C/D…). + private static func fallbackLabel(for index: Int) -> String { + guard let scalar = Unicode.Scalar(0x41 + index) else { return "" } + return String(Character(scalar)) + } } public extension ScenarioPhilosopherDTO { - func toDomain() -> ScenarioPhilosopher { - ScenarioPhilosopher(label: label, name: name, stance: stance, imageUrl: imageUrl) + func toDomain(fallbackLabel: String) -> ScenarioPhilosopher { + ScenarioPhilosopher( + label: label ?? fallbackLabel, + name: name, + stance: stance, + imageUrl: imageUrl + ) } } diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift new file mode 100644 index 00000000..fc3f0728 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleVoteStatsDataDTO+.swift @@ -0,0 +1,41 @@ +// +// BattleVoteStatsDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleVoteStatsDataDTO { + func toDomain() -> BattleVoteStats { + BattleVoteStats( + options: options.map { $0.toDomain() }, + totalCount: totalCount, + updatedAt: updatedAt.flatMap(Self.parseISO8601) + ) + } + + /// `2026-05-22T13:28:16.697Z` 형태의 ISO8601 (fractional seconds 포함) 파싱. + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public extension BattleVoteStatsOptionDTO { + func toDomain() -> BattleVoteStatsOption { + BattleVoteStatsOption( + optionId: optionId, + label: label, + title: title ?? "", + isCorrect: isCorrect ?? false, + voteCount: voteCount, + ratio: ratio > 1 ? ratio / 100.0 : ratio, + stance: stance ?? "", + imageUrl: imageUrl + ) + } +} diff --git a/Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift new file mode 100644 index 00000000..e8c57969 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/RecommendedBattleDataDTO+.swift @@ -0,0 +1,50 @@ +// +// RecommendedBattleDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension RecommendedBattlePageDataDTO { + func toDomain() -> RecommendedBattlePage { + RecommendedBattlePage( + items: items.map { $0.toDomain() }, + nextCursor: nextCursor, + hasNext: hasNext + ) + } +} + +public extension RecommendedBattleDTO { + func toDomain() -> RecommendedBattle { + RecommendedBattle( + battleId: battleId, + title: title ?? "", + summary: summary ?? "", + audioDuration: audioDuration ?? 0, + viewCount: viewCount ?? 0, + tags: (tags ?? []).map { $0.toDomain() }, + participantsCount: participantsCount ?? 0, + options: (options ?? []).map { $0.toDomain() } + ) + } +} + +public extension RecommendedBattleTagDTO { + func toDomain() -> RecommendedBattleTag { + RecommendedBattleTag(tagId: tagId, name: name ?? "") + } +} + +public extension RecommendedBattleOptionDTO { + func toDomain() -> RecommendedBattleOption { + RecommendedBattleOption( + optionId: optionId, + title: title ?? "", + stance: stance ?? "", + representative: representative ?? "", + imageUrl: imageUrl + ) + } +} diff --git a/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift b/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift new file mode 100644 index 00000000..d084a38d --- /dev/null +++ b/Projects/Data/Model/Sources/Comment/DTO/CommentLikeDataDTO.swift @@ -0,0 +1,15 @@ +// +// CommentLikeDataDTO.swift +// Model +// + +import Foundation + +public struct CommentLikeDataDTO: Decodable { + public let perspectiveId: Int + public let likeCount: Int + /// perspective 좋아요 응답에는 isLiked 가 없어 옵셔널. + public let isLiked: Bool? +} + +public typealias CommentLikeResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift b/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift new file mode 100644 index 00000000..3ad4e2ed --- /dev/null +++ b/Projects/Data/Model/Sources/Comment/Mapper/CommentLikeDataDTO+.swift @@ -0,0 +1,17 @@ +// +// CommentLikeDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension CommentLikeDataDTO { + func toDomain() -> CommentLikeResult { + CommentLikeResult( + perspectiveId: perspectiveId, + likeCount: likeCount, + isLiked: isLiked ?? false + ) + } +} diff --git a/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift b/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift index 44fd2831..3fffc24a 100644 --- a/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift +++ b/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift @@ -15,7 +15,10 @@ public extension TagDTO { } public extension EditorPickDTO { - func toDomain(position: Int, total: Int) -> HeroBattle { + func toDomain( + position: Int, + total: Int + ) -> HeroBattle { HeroBattle( battleId: battleId, badge: "EDITOR PICK", diff --git a/Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift b/Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift new file mode 100644 index 00000000..2c1c589d --- /dev/null +++ b/Projects/Data/Model/Sources/Perspective/DTO/PerspectiveCommentDataDTO.swift @@ -0,0 +1,41 @@ +// +// PerspectiveCommentDataDTO.swift +// Model +// + +import Foundation + +public struct PerspectiveCommentPageDataDTO: Decodable { + public let items: [PerspectiveCommentDTO] + public let nextCursor: String? + public let hasNext: Bool +} + +public struct PerspectiveCommentDTO: Decodable { + public let commentId: Int + public let user: PerspectiveCommentUserDTO + public let stance: String? + public let content: String + public let likeCount: Int + public let isLiked: Bool? + public let isMine: Bool? + public let createdAt: String? +} + +public struct PerspectiveCommentUserDTO: Decodable { + public let userTag: String? + public let nickname: String? + public let characterType: String? + public let characterImageUrl: String? +} + +/// POST/PUT /api/v1/perspectives/{pid}/comments[/{cid}] 응답. +public struct PerspectiveCommentMutationDataDTO: Decodable { + public let commentId: Int + public let content: String + public let updatedAt: String? +} + +public typealias PerspectiveCommentPageResponseDTO = BaseResponseDTO +public typealias PerspectiveCommentMutationResponseDTO = BaseResponseDTO +public typealias PerspectiveDetailResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift b/Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift new file mode 100644 index 00000000..ca8246dc --- /dev/null +++ b/Projects/Data/Model/Sources/Perspective/Mapper/PerspectiveCommentDataDTO+.swift @@ -0,0 +1,69 @@ +// +// PerspectiveCommentDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension PerspectiveCommentPageDataDTO { + func toDomain() -> PerspectiveCommentPage { + PerspectiveCommentPage( + items: items.map { $0.toDomain() }, + nextCursor: nextCursor, + hasNext: hasNext + ) + } +} + +public extension PerspectiveCommentDTO { + func toDomain() -> PerspectiveComment { + PerspectiveComment( + commentId: commentId, + user: user.toDomain(), + stance: stance ?? "", + content: content, + likeCount: likeCount, + isLiked: isLiked ?? false, + isMine: isMine ?? false, + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public extension PerspectiveCommentUserDTO { + func toDomain() -> PerspectiveCommentUser { + PerspectiveCommentUser( + userTag: userTag ?? "", + nickname: nickname ?? "익명", + characterType: characterType ?? "", + characterImageUrl: characterImageUrl + ) + } +} + +public extension PerspectiveCommentMutationDataDTO { + func toDomain() -> PerspectiveCommentMutationResult { + PerspectiveCommentMutationResult( + commentId: commentId, + content: content, + updatedAt: updatedAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift index 08388a0a..06e56afb 100644 --- a/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift @@ -18,7 +18,8 @@ public final class AudioPlayerRepositoryImpl: AudioPlayerInterface, @unchecked S service = AudioPlayerService.shared } - public func load(url: URL) async { + @discardableResult + public func load(url: URL) async -> Bool { await service.load(url: url) } @@ -78,10 +79,16 @@ final class AudioPlayerService { } } - func load(url: URL) { + func load(url: URL) async -> Bool { let item = AVPlayerItem(url: url) player.replaceCurrentItem(with: item) player.seek(to: .zero) + // 자산이 실제 재생 가능한지 확인 → 실패 시 오류 배너 노출 트리거. + do { + return try await item.asset.load(.isPlayable) + } catch { + return false + } } func play() { player.play() } diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index 4abab601..f0b08674 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -32,13 +32,16 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { guard let data = dto.data else { let message = dto.error?.message ?? "배틀 상세 응답이 비어 있습니다" Log.error("[BattleRepositoryImpl] empty battleDetail payload: \(message)") - throw AuthError.backendError(message) + throw BattleError.backendError(message) } return data.toDomain() } - public func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult { + public func submitPreVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { let dto: PreVoteResponseDTO = try await provider.request( .preVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) ) @@ -46,12 +49,113 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { guard let data = dto.data else { let message = dto.error?.message ?? "사전 투표 응답이 비어 있습니다" Log.error("[BattleRepositoryImpl] empty preVote payload: \(message)") - throw AuthError.backendError(message) + throw BattleError.backendError(message) } return data.toDomain() } + public func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats { + let dto: BattleVoteStatsResponseDTO = try await provider.request( + .voteStats(battleId: battleId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "투표 통계 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty voteStats payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } + + public func submitPostVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { + let dto: PreVoteResponseDTO = try await provider.request( + .postVote(battleId: battleId, body: PreVoteRequest(optionId: optionId)) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "최종 투표 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty postVote payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } + + public func fetchPerspectives( + battleId: Int, + cursor: String?, + size: Int?, + optionId: Int?, + sort: BattlePerspectiveSort? + ) async throws -> BattlePerspectivePage { + let dto: BattlePerspectivePageResponseDTO = try await provider.request( + .perspectives( + battleId: battleId, + query: PerspectivesQueryRequest( + cursor: cursor, + size: size, + optionId: optionId, + sort: sort?.queryValue + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "댓글 목록 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty perspectives payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } + + public func createPerspective( + battleId: Int, + content: String, + optionId: Int? + ) async throws -> BattlePerspective { + let dto: CreatePerspectiveResponseDTO = try await provider.request( + .createPerspective( + battleId: battleId, + body: CreatePerspectiveRequest(content: content, optionId: optionId) + ) + ) + + guard dto.data != nil else { + let message = dto.error?.message ?? "댓글 작성 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty createPerspective payload: \(message)") + throw BattleError.backendError(message) + } + + guard let perspective = try await fetchMyPerspective(battleId: battleId) else { + let message = "작성한 댓글을 다시 조회할 수 없습니다" + Log.error("[BattleRepositoryImpl] empty created perspective payload: \(message)") + throw BattleError.backendError(message) + } + + return perspective + } + + public func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? { + let dto: BaseResponseDTO + do { + dto = try await provider.request(.myPerspective(battleId: battleId)) + } catch { + Log.debug("[BattleRepositoryImpl] fetchMyPerspective failed (no participation): \(error.localizedDescription)") + return nil + } + + if dto.statusCode >= 400 { + return nil + } + return dto.data?.toDomain() + } + public func fetchScenario(battleId: Int) async throws -> BattleScenario { let dto: BattleScenarioResponseDTO = try await provider.request( .scenario(battleId: battleId) @@ -60,7 +164,21 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { guard let data = dto.data else { let message = dto.error?.message ?? "시나리오 응답이 비어 있습니다" Log.error("[BattleRepositoryImpl] empty scenario payload: \(message)") - throw AuthError.backendError(message) + throw BattleError.backendError(message) + } + + return data.toDomain() + } + + public func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage { + let dto: RecommendedBattlePageResponseDTO = try await provider.request( + .recommendations(battleId: battleId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "추천 배틀 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty recommendations payload: \(message)") + throw BattleError.backendError(message) } return data.toDomain() diff --git a/Projects/Data/Repository/Sources/Comment/CommentRepositoryImpl.swift b/Projects/Data/Repository/Sources/Comment/CommentRepositoryImpl.swift new file mode 100644 index 00000000..7d90eb01 --- /dev/null +++ b/Projects/Data/Repository/Sources/Comment/CommentRepositoryImpl.swift @@ -0,0 +1,50 @@ +// +// CommentRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class CommentRepositoryImpl: CommentInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func likeComment(commentId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request(.like(commentId: commentId)) + + guard let data = dto.data else { + let message = dto.error?.message ?? "댓글 좋아요 응답이 비어 있습니다" + Log.error("[CommentRepositoryImpl] empty like payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func unlikeComment(commentId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request(.unlike(commentId: commentId)) + + guard let data = dto.data else { + let message = dto.error?.message ?? "댓글 좋아요 취소 응답이 비어 있습니다" + Log.error("[CommentRepositoryImpl] empty unlike payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } +} diff --git a/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift b/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift index 6feac255..8b215ed1 100644 --- a/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift +++ b/Projects/Data/Repository/Sources/Google/GoogleOAuthConfiguration.swift @@ -22,7 +22,10 @@ public struct GoogleOAuthConfiguration { !serverClientID.contains("GOOGLE_CLIENT_ID") } - public init(clientID: String, serverClientID: String) { + public init( + clientID: String, + serverClientID: String + ) { self.clientID = clientID self.serverClientID = serverClientID } diff --git a/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift index a5aeaf3a..1b452346 100644 --- a/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/OAuth/Apple/AppleOAuthRepositoryImpl.swift @@ -31,7 +31,10 @@ public final class AppleOAuthRepositoryImpl: NSObject, AppleOAuthInterface, @unc public override init() { } - public func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload { + public func signInWithCredential( + _ credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> AppleOAuthPayload { // 받은 credential으로 직접 payload 생성 guard let identityTokenData = credential.identityToken, let identityToken = String(data: identityTokenData, encoding: .utf8) @@ -92,7 +95,10 @@ public final class AppleOAuthRepositoryImpl: NSObject, AppleOAuthInterface, @unc // MARK: - ASAuthorizationControllerDelegate extension AppleOAuthRepositoryImpl: ASAuthorizationControllerDelegate { - public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + public func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { finishSignIn(with: .failure(AuthError.invalidCredential("Invalid credential type"))) return diff --git a/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift b/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift index 4e2b38c9..a26de3ea 100644 --- a/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift +++ b/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift @@ -243,7 +243,10 @@ final class OAuthWebViewController: UIViewController { } } - private func finish(_ result: Result, animated: Bool = true) { + private func finish( + _ result: Result, + animated: Bool = true + ) { guard !didFinish else { return } didFinish = true let completion = onComplete @@ -292,19 +295,28 @@ private final class OAuthSheetDragHandleView: UIControl { fatalError("init(coder:) has not been implemented") } - override func beginTracking(_ touch: UITouch, with _: UIEvent?) -> Bool { + override func beginTracking( + _ touch: UITouch, + with _: UIEvent? + ) -> Bool { initialTouchY = touch.location(in: nil).y return true } - override func continueTracking(_ touch: UITouch, with _: UIEvent?) -> Bool { + override func continueTracking( + _ touch: UITouch, + with _: UIEvent? + ) -> Bool { guard let initialTouchY else { return false } let currentY = touch.location(in: nil).y events.send(.changed(currentY - initialTouchY)) return true } - override func endTracking(_ touch: UITouch?, with _: UIEvent?) { + override func endTracking( + _ touch: UITouch?, + with _: UIEvent? + ) { defer { initialTouchY = nil } guard let touch, let initialTouchY diff --git a/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift new file mode 100644 index 00000000..03aa7dff --- /dev/null +++ b/Projects/Data/Repository/Sources/Perspective/PerspectiveRepositoryImpl.swift @@ -0,0 +1,199 @@ +// +// PerspectiveRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + let dto: PerspectiveDetailResponseDTO = try await provider.request( + .detail(perspectiveId: perspectiveId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "perspective 상세 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty detail payload: \(message)") + throw PerspectiveError.backendError(message) + } + + return data.toDomain() + } + + public func fetchLabeledComments( + perspectiveId: Int, + cursor: String?, + size: Int? + ) async throws -> PerspectiveCommentPage { + let dto: PerspectiveCommentPageResponseDTO = try await provider.request( + .listLabeledComments(perspectiveId: perspectiveId, cursor: cursor, size: size) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "대댓글 목록 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty list payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func createComment( + perspectiveId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + let dto: PerspectiveCommentMutationResponseDTO = try await provider.request( + .createComment(perspectiveId: perspectiveId, body: PerspectiveCommentBody(content: content)) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "대댓글 작성 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty create payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func updateComment( + perspectiveId: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + let dto: PerspectiveCommentMutationResponseDTO = try await provider.request( + .updateComment( + perspectiveId: perspectiveId, + commentId: commentId, + body: PerspectiveCommentBody(content: content) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "대댓글 수정 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty update payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func deleteComment( + perspectiveId: Int, + commentId: Int + ) async throws { + let dto: BaseResponseDTO = try await provider.request( + .deleteComment(perspectiveId: perspectiveId, commentId: commentId) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "대댓글 삭제 실패" + throw CommentError.backendError(message) + } + } + + public func updatePerspective(perspectiveId: Int, content: String) async throws { + let dto: BaseResponseDTO = try await provider.request( + .updatePerspective(perspectiveId: perspectiveId, body: PerspectiveCommentBody(content: content)) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "perspective 수정 실패" + throw PerspectiveError.backendError(message) + } + } + + public func deletePerspective(perspectiveId: Int) async throws { + let dto: BaseResponseDTO = try await provider.request( + .deletePerspective(perspectiveId: perspectiveId) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "perspective 삭제 실패" + throw PerspectiveError.backendError(message) + } + } + + public func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request( + .likePerspective(perspectiveId: perspectiveId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "관점 좋아요 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty like payload: \(message)") + throw CommentError.backendError(message) + } + + return CommentLikeResult(perspectiveId: data.perspectiveId, likeCount: data.likeCount, isLiked: true) + } + + public func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request( + .unlikePerspective(perspectiveId: perspectiveId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "관점 좋아요 취소 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty unlike payload: \(message)") + throw CommentError.backendError(message) + } + + return CommentLikeResult(perspectiveId: data.perspectiveId, likeCount: data.likeCount, isLiked: false) + } + + public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { + let dto: CommentLikeResponseDTO = try await provider.request( + .fetchPerspectiveLikes(perspectiveId: perspectiveId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "관점 좋아요 수 응답이 비어 있습니다" + Log.error("[PerspectiveRepositoryImpl] empty like payload: \(message)") + throw CommentError.backendError(message) + } + + return data.toDomain() + } + + public func reportPerspective(perspectiveId: Int) async throws { + let dto: BaseResponseDTO = try await provider.request( + .reportPerspective(perspectiveId: perspectiveId) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "관점 신고 실패" + throw CommentError.backendError(message) + } + } + + public func reportComment(perspectiveId: Int, commentId: Int) async throws { + let dto: BaseResponseDTO = try await provider.request( + .reportComment(perspectiveId: perspectiveId, commentId: commentId) + ) + + if dto.statusCode >= 400 { + let message = dto.error?.message ?? "댓글 신고 실패" + throw CommentError.backendError(message) + } + } +} + +public struct EmptyDTO: Decodable {} diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 988bea3d..58f9c096 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -13,7 +13,13 @@ import AsyncMoya public enum BattleService { case detail(battleId: Int) case preVote(battleId: Int, body: PreVoteRequest) + case postVote(battleId: Int, body: PreVoteRequest) case scenario(battleId: Int) + case voteStats(battleId: Int) + case perspectives(battleId: Int, query: PerspectivesQueryRequest) + case createPerspective(battleId: Int, body: CreatePerspectiveRequest) + case myPerspective(battleId: Int) + case recommendations(battleId: Int) } extension BattleService: BaseTargetType { @@ -24,11 +30,23 @@ extension BattleService: BaseTargetType { public var urlPath: String { switch self { case let .detail(battleId): - BattleAPI.detail(battleId: battleId).description + return BattleAPI.detail(battleId: battleId).description case let .preVote(battleId, _): - BattleAPI.preVote(battleId: battleId).description + return BattleAPI.preVote(battleId: battleId).description + case let .postVote(battleId, _): + return BattleAPI.postVote(battleId: battleId).description case let .scenario(battleId): - BattleAPI.scenario(battleId: battleId).description + return BattleAPI.scenario(battleId: battleId).description + case let .voteStats(battleId): + return BattleAPI.voteStats(battleId: battleId).description + case let .perspectives(battleId, _): + return BattleAPI.perspectives(battleId: battleId).description + case let .createPerspective(battleId, _): + return BattleAPI.perspectives(battleId: battleId).description + case let .myPerspective(battleId): + return BattleAPI.myPerspective(battleId: battleId).description + case let .recommendations(battleId): + return BattleAPI.recommendations(battleId: battleId).description } } @@ -36,27 +54,38 @@ extension BattleService: BaseTargetType { public var method: Moya.Method { switch self { - case .detail: - .get - case .preVote: - .post - case .scenario: - .get + case .detail, .scenario, .voteStats, .perspectives, .myPerspective, .recommendations: + return .get + case .preVote, .postVote, .createPerspective: + return .post } } public var parameters: [String: Any]? { switch self { case .detail: - nil + return nil case let .preVote(_, body): - body.toDictionary + return body.toDictionary + case let .postVote(_, body): + return body.toDictionary case .scenario: - nil + return nil + case .voteStats: + return nil + case let .perspectives(_, query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict + case let .createPerspective(_, body): + return body.toDictionary + case .myPerspective: + return nil + case .recommendations: + return nil } } public var headers: [String: String]? { - APIHeader.baseHeader + return APIHeader.baseHeader } } diff --git a/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift b/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift new file mode 100644 index 00000000..f9e94eec --- /dev/null +++ b/Projects/Data/Service/Sources/Battle/CreatePerspectiveRequest.swift @@ -0,0 +1,29 @@ +// +// CreatePerspectiveRequest.swift +// Service +// +// Created by Wonji Suh on 5/24/26. +// + +import Foundation + +public struct CreatePerspectiveRequest: Encodable { + public let content: String + public let optionId: Int? + + public init(content: String, optionId: Int? = nil) { + self.content = content + self.optionId = optionId + } + + private enum CodingKeys: String, CodingKey { + case content + case optionId + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(content, forKey: .content) + try container.encodeIfPresent(optionId, forKey: .optionId) + } +} diff --git a/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift b/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift new file mode 100644 index 00000000..ca617042 --- /dev/null +++ b/Projects/Data/Service/Sources/Battle/PerspectivesQueryRequest.swift @@ -0,0 +1,27 @@ +// +// PerspectivesQueryRequest.swift +// Service +// +// GET /api/v1/battles/{battleId}/perspectives 쿼리 파라미터. +// + +import Foundation + +public struct PerspectivesQueryRequest: Encodable { + public let cursor: String? + public let size: Int? + public let optionId: Int? + public let sort: String? + + public init( + cursor: String? = nil, + size: Int? = nil, + optionId: Int? = nil, + sort: String? = nil + ) { + self.cursor = cursor + self.size = size + self.optionId = optionId + self.sort = sort + } +} diff --git a/Projects/Data/Service/Sources/Comment/CommentService.swift b/Projects/Data/Service/Sources/Comment/CommentService.swift new file mode 100644 index 00000000..35cb4b6b --- /dev/null +++ b/Projects/Data/Service/Sources/Comment/CommentService.swift @@ -0,0 +1,48 @@ +// +// CommentService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum CommentService { + case like(commentId: Int) + case unlike(commentId: Int) +} + +extension CommentService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .comment } + + public var urlPath: String { + switch self { + case let .like(commentId): + CommentAPI.like(commentId: commentId).description + case let .unlike(commentId): + CommentAPI.unlike(commentId: commentId).description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .like: + .post + case .unlike: + .delete + } + } + + public var parameters: [String: Any]? { nil } + + public var headers: [String: String]? { + APIHeader.baseHeader + } +} diff --git a/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift new file mode 100644 index 00000000..97dcd5e0 --- /dev/null +++ b/Projects/Data/Service/Sources/Perspective/PerspectiveService.swift @@ -0,0 +1,111 @@ +// +// PerspectiveService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public struct PerspectiveCommentBody: Encodable { + public let content: String + public init(content: String) { self.content = content } +} + +public enum PerspectiveService { + case detail(perspectiveId: Int) + case listLabeledComments(perspectiveId: Int, cursor: String?, size: Int?) + case createComment(perspectiveId: Int, body: PerspectiveCommentBody) + case updateComment(perspectiveId: Int, commentId: Int, body: PerspectiveCommentBody) + case deleteComment(perspectiveId: Int, commentId: Int) + case updatePerspective(perspectiveId: Int, body: PerspectiveCommentBody) + case deletePerspective(perspectiveId: Int) + case likePerspective(perspectiveId: Int) + case unlikePerspective(perspectiveId: Int) + case fetchPerspectiveLikes(perspectiveId: Int) + case reportPerspective(perspectiveId: Int) + case reportComment(perspectiveId: Int, commentId: Int) +} + +extension PerspectiveService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .perspective } + + public var urlPath: String { + switch self { + case let .detail(perspectiveId): + return PerspectiveAPI.detail(perspectiveId: perspectiveId).description + case let .listLabeledComments(perspectiveId, _, _): + return PerspectiveAPI.listLabeledComments(perspectiveId: perspectiveId).description + case let .createComment(perspectiveId, _): + return PerspectiveAPI.createComment(perspectiveId: perspectiveId).description + case let .updateComment(perspectiveId, commentId, _): + return PerspectiveAPI.updateComment(perspectiveId: perspectiveId, commentId: commentId).description + case let .deleteComment(perspectiveId, commentId): + return PerspectiveAPI.deleteComment(perspectiveId: perspectiveId, commentId: commentId).description + case let .updatePerspective(perspectiveId, _): + return PerspectiveAPI.detail(perspectiveId: perspectiveId).description + case let .deletePerspective(perspectiveId): + return PerspectiveAPI.detail(perspectiveId: perspectiveId).description + case let .likePerspective(perspectiveId): + return PerspectiveAPI.likes(perspectiveId: perspectiveId).description + case let .unlikePerspective(perspectiveId): + return PerspectiveAPI.likes(perspectiveId: perspectiveId).description + case let .fetchPerspectiveLikes(perspectiveId): + return PerspectiveAPI.likes(perspectiveId: perspectiveId).description + case let .reportPerspective(perspectiveId): + return PerspectiveAPI.reports(perspectiveId: perspectiveId).description + case let .reportComment(perspectiveId, commentId): + return PerspectiveAPI.reportComment(perspectiveId: perspectiveId, commentId: commentId).description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .detail, .listLabeledComments, .fetchPerspectiveLikes: + return .get + case .createComment, .likePerspective, .reportPerspective, .reportComment: + return .post + case .updateComment, .updatePerspective: + return .patch + case .deleteComment, .deletePerspective, .unlikePerspective: + return .delete + } + } + + public var parameters: [String: Any]? { + switch self { + case .detail: + return nil + case let .listLabeledComments(_, cursor, size): + var query: [String: Any] = [:] + if let cursor { query["cursor"] = cursor } + if let size { query["size"] = size } + return query.isEmpty ? nil : query + case let .createComment(_, body): + return body.toDictionary + case let .updateComment(_, _, body): + return body.toDictionary + case let .updatePerspective(_, body): + return body.toDictionary + case .deleteComment: + return nil + case .deletePerspective: + return nil + case .likePerspective, .unlikePerspective, .fetchPerspectiveLikes: + return nil + case .reportPerspective, .reportComment: + return nil + } + } + + public var headers: [String: String]? { + APIHeader.baseHeader + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift index 8e2a7b3e..d3dd4624 100644 --- a/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Apple/AppleOAuthInterface.swift @@ -14,7 +14,10 @@ import WeaveDI public protocol AppleOAuthInterface: Sendable { func signIn() async throws -> AppleOAuthPayload - func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload + func signInWithCredential( + _ credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> AppleOAuthPayload } // MARK: - Dependencies diff --git a/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift b/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift index 3e92d83c..1a70055a 100644 --- a/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift +++ b/Projects/Domain/DomainInterface/Sources/Apple/MockAppleOAuthRepository.swift @@ -98,7 +98,10 @@ public actor MockAppleOAuthRepository: AppleOAuthInterface { // MARK: - AppleOAuthRepositoryProtocol Implementation - public func signInWithCredential(_ credential: ASAuthorizationAppleIDCredential, nonce: String) async throws -> AppleOAuthPayload { + public func signInWithCredential( + _ credential: ASAuthorizationAppleIDCredential, + nonce: String + ) async throws -> AppleOAuthPayload { // Track call signInCallCount += 1 lastSignInCall = Date() diff --git a/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift b/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift index e8f9f2e0..051304b3 100644 --- a/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/AudioPlayer/AudioPlayerInterface.swift @@ -10,7 +10,9 @@ import Foundation import WeaveDI public protocol AudioPlayerInterface: Sendable { - func load(url: URL) async + /// 음원을 로드하고 재생 가능 여부를 반환한다. (false = 오디오 로딩 실패) + @discardableResult + func load(url: URL) async -> Bool func play() async func pause() async func seek(to time: TimeInterval) async @@ -20,7 +22,7 @@ public protocol AudioPlayerInterface: Sendable { public struct DefaultAudioPlayerImpl: AudioPlayerInterface { public init() {} - public func load(url _: URL) async {} + public func load(url _: URL) async -> Bool { true } public func play() async {} public func pause() async {} public func seek(to _: TimeInterval) async {} diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index 09f7d8b3..3627f339 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -9,8 +9,30 @@ import WeaveDI public protocol BattleInterface: Sendable { func fetchBattle(battleId: Int) async throws -> BattleDetail - func submitPreVote(battleId: Int, optionId: Int) async throws -> PreVoteResult + func submitPreVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult + func submitPostVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult func fetchScenario(battleId: Int) async throws -> BattleScenario + func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats + func fetchPerspectives( + battleId: Int, + cursor: String?, + size: Int?, + optionId: Int?, + sort: BattlePerspectiveSort? + ) async throws -> BattlePerspectivePage + func createPerspective( + battleId: Int, + content: String, + optionId: Int? + ) async throws -> BattlePerspective + func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? + func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage } public struct BattleRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 5d5894ac..4e618cc6 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -32,7 +32,31 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { ) } - public func submitPreVote(battleId _: Int, optionId _: Int) async throws -> PreVoteResult { + public func submitPostVote( + battleId _: Int, + optionId _: Int + ) async throws -> PreVoteResult { + PreVoteResult(voteId: 0, status: .none) + } + + public func fetchVoteStats(battleId _: Int) async throws -> BattleVoteStats { + BattleVoteStats(options: [], totalCount: 0, updatedAt: nil) + } + + public func fetchPerspectives( + battleId _: Int, + cursor _: String?, + size _: Int?, + optionId _: Int?, + sort _: BattlePerspectiveSort? + ) async throws -> BattlePerspectivePage { + BattlePerspectivePage(items: [], nextCursor: nil, hasNext: false) + } + + public func submitPreVote( + battleId _: Int, + optionId _: Int + ) async throws -> PreVoteResult { PreVoteResult(voteId: 0, status: .none) } @@ -48,4 +72,30 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { nodes: [] ) } + + public func createPerspective( + battleId _: Int, + content: String, + optionId _: Int? + ) async throws -> BattlePerspective { + BattlePerspective( + perspectiveId: 0, + user: BattlePerspectiveUser(userTag: "", nickname: "나", characterType: "", characterImageUrl: nil), + option: BattlePerspectiveOption(optionId: 0, label: nil, title: "", stance: ""), + content: content, + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: true, + createdAt: Date() + ) + } + + public func fetchMyPerspective(battleId _: Int) async throws -> BattlePerspective? { + nil + } + + public func fetchRecommendedBattles(battleId _: Int) async throws -> RecommendedBattlePage { + RecommendedBattlePage(items: [], nextCursor: nil, hasNext: false) + } } diff --git a/Projects/Domain/DomainInterface/Sources/Comment/CommentInterface.swift b/Projects/Domain/DomainInterface/Sources/Comment/CommentInterface.swift new file mode 100644 index 00000000..93c9bd09 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Comment/CommentInterface.swift @@ -0,0 +1,45 @@ +// +// CommentInterface.swift +// DomainInterface +// + +import Dependencies +import Entity +import Foundation +import WeaveDI + +public protocol CommentInterface: Sendable { + func likeComment(commentId: Int) async throws -> CommentLikeResult + func unlikeComment(commentId: Int) async throws -> CommentLikeResult +} + +public struct DefaultCommentRepositoryImpl: CommentInterface { + public init() {} + + public func likeComment(commentId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: commentId, likeCount: 0, isLiked: true) + } + + public func unlikeComment(commentId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: commentId, likeCount: 0, isLiked: false) + } +} + +public struct CommentRepositoryDependency: DependencyKey { + public static var liveValue: CommentInterface { + UnifiedDI.resolve(CommentInterface.self) ?? DefaultCommentRepositoryImpl() + } + + public static var testValue: CommentInterface { + UnifiedDI.resolve(CommentInterface.self) ?? DefaultCommentRepositoryImpl() + } + + public static var previewValue: CommentInterface = liveValue +} + +public extension DependencyValues { + var commentRepository: CommentInterface { + get { self[CommentRepositoryDependency.self] } + set { self[CommentRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift b/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift index e81f611d..982444ae 100644 --- a/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift +++ b/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift @@ -13,7 +13,10 @@ public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendabl public init() {} - public func save(accessToken: String, refreshToken: String) { + public func save( + accessToken: String, + refreshToken: String + ) { accessTokenStorage = accessToken refreshTokenStorage = refreshToken } diff --git a/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift index ffefaffa..753630dc 100644 --- a/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift @@ -9,7 +9,10 @@ import Foundation import WeaveDI public protocol KeychainManaging: Sendable { - func save(accessToken: String, refreshToken: String) + func save( + accessToken: String, + refreshToken: String + ) func saveAccessToken(_ token: String) func clearAccessToken() func saveRefreshToken(_ token: String) diff --git a/Projects/Domain/DomainInterface/Sources/Manager/MockKeychainManager.swift b/Projects/Domain/DomainInterface/Sources/Manager/MockKeychainManager.swift index 97cb3311..611aa706 100644 --- a/Projects/Domain/DomainInterface/Sources/Manager/MockKeychainManager.swift +++ b/Projects/Domain/DomainInterface/Sources/Manager/MockKeychainManager.swift @@ -55,7 +55,10 @@ public final class MockKeychainManager: KeychainManaging, @unchecked Sendable { // MARK: - KeychainManaging Implementation - public func save(accessToken: String, refreshToken: String) { + public func save( + accessToken: String, + refreshToken: String + ) { saveCallCount += 1 switch configuration { diff --git a/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift new file mode 100644 index 00000000..7b5a82d0 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Perspective/DefaultPerspectiveRepositoryImpl.swift @@ -0,0 +1,75 @@ +// +// DefaultPerspectiveRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 6/3/26. +// + +import Entity +import Foundation + +public struct DefaultPerspectiveRepositoryImpl: PerspectiveInterface { + public init() {} + + public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + BattlePerspective( + perspectiveId: perspectiveId, + user: BattlePerspectiveUser(userTag: "", nickname: "", characterType: "", characterImageUrl: nil), + option: BattlePerspectiveOption(optionId: 0, label: nil, title: "", stance: ""), + content: "", + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: false, + createdAt: nil + ) + } + + public func fetchLabeledComments( + perspectiveId _: Int, + cursor _: String?, + size _: Int? + ) async throws -> PerspectiveCommentPage { + PerspectiveCommentPage(items: [], nextCursor: nil, hasNext: false) + } + + public func createComment( + perspectiveId _: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + PerspectiveCommentMutationResult(commentId: 0, content: content, updatedAt: nil) + } + + public func updateComment( + perspectiveId _: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + PerspectiveCommentMutationResult(commentId: commentId, content: content, updatedAt: nil) + } + + public func deleteComment( + perspectiveId _: Int, + commentId _: Int + ) async throws {} + + public func updatePerspective(perspectiveId _: Int, content _: String) async throws {} + + public func deletePerspective(perspectiveId _: Int) async throws {} + + public func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: perspectiveId, likeCount: 0, isLiked: true) + } + + public func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: perspectiveId, likeCount: 0, isLiked: false) + } + + public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { + CommentLikeResult(perspectiveId: perspectiveId, likeCount: 0, isLiked: false) + } + + public func reportPerspective(perspectiveId _: Int) async throws {} + + public func reportComment(perspectiveId _: Int, commentId _: Int) async throws {} +} diff --git a/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift new file mode 100644 index 00000000..2634b547 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Perspective/PerspectiveInterface.swift @@ -0,0 +1,57 @@ +// +// PerspectiveInterface.swift +// DomainInterface +// + +import Dependencies +import Entity +import Foundation +import WeaveDI + +public protocol PerspectiveInterface: Sendable { + func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective + func fetchLabeledComments( + perspectiveId: Int, + cursor: String?, + size: Int? + ) async throws -> PerspectiveCommentPage + func createComment( + perspectiveId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult + func updateComment( + perspectiveId: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult + func deleteComment( + perspectiveId: Int, + commentId: Int + ) async throws + func updatePerspective(perspectiveId: Int, content: String) async throws + func deletePerspective(perspectiveId: Int) async throws + func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult + func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult + func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult + func reportPerspective(perspectiveId: Int) async throws + func reportComment(perspectiveId: Int, commentId: Int) async throws +} + +public struct PerspectiveRepositoryDependency: DependencyKey { + public static var liveValue: PerspectiveInterface { + UnifiedDI.resolve(PerspectiveInterface.self) ?? DefaultPerspectiveRepositoryImpl() + } + + public static var testValue: PerspectiveInterface { + UnifiedDI.resolve(PerspectiveInterface.self) ?? DefaultPerspectiveRepositoryImpl() + } + + public static var previewValue: PerspectiveInterface = liveValue +} + +public extension DependencyValues { + var perspectiveRepository: PerspectiveInterface { + get { self[PerspectiveRepositoryDependency.self] } + set { self[PerspectiveRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/Entity/Sources/Error/BattleError.swift b/Projects/Domain/Entity/Sources/Error/BattleError.swift new file mode 100644 index 00000000..23706560 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/BattleError.swift @@ -0,0 +1,82 @@ +// +// BattleError.swift +// Entity +// +// 배틀 도메인 (배틀 상세 / 시나리오 / 사전·최종 투표 / 투표 통계) 의 표준 에러. +// + +import Foundation + +public enum BattleError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): + "네트워크 오류: \(message)" + case let .decodingError(message): + "데이터 파싱 오류: \(message)" + case .noData: + "배틀 데이터가 없습니다" + case .unauthorized: + "권한이 없습니다" + case let .backendError(message): + "배틀 처리 실패: \(message)" + case let .serverError(code): + "서버 오류 (코드: \(code))" + case let .unknown(message): + "알 수 없는 오류: \(message)" + } + } + + public var failureReason: String? { + switch self { + case .networkError: + "네트워크 연결을 확인해주세요" + case .decodingError: + "서버 응답 데이터가 올바르지 않습니다" + case .noData: + "조회된 배틀 정보가 없습니다" + case .unauthorized: + "로그인이 필요합니다" + case .backendError: + "백엔드 응답에서 비즈니스 오류가 보고되었습니다" + case .serverError: + "잠시 후 다시 시도해주세요" + case .unknown: + "예상치 못한 오류가 발생했습니다" + } + } + + public var recoverySuggestion: String? { + switch self { + case .networkError: + "인터넷 연결을 확인하고 다시 시도해주세요" + case .decodingError: + "앱을 다시 시작해보세요" + case .noData: + "새로고침하거나 관리자에게 문의하세요" + case .unauthorized: + "다시 로그인해주세요" + case .backendError: + "다른 옵션을 선택하거나 잠시 후 다시 시도해주세요" + case .serverError: + "잠시 후 다시 시도하거나 고객센터에 문의하세요" + case .unknown: + "문제가 지속되면 고객센터에 문의해주세요" + } + } +} + +public extension BattleError { + static func from(_ error: Error) -> BattleError { + if let battleError = error as? BattleError { return battleError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Domain/Entity/Sources/Error/CommentError.swift b/Projects/Domain/Entity/Sources/Error/CommentError.swift new file mode 100644 index 00000000..d46c77b2 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/CommentError.swift @@ -0,0 +1,35 @@ +// +// CommentError.swift +// Entity +// + +import Foundation + +public enum CommentError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): "네트워크 오류: \(message)" + case let .decodingError(message): "데이터 파싱 오류: \(message)" + case .noData: "댓글 데이터가 없습니다" + case .unauthorized: "권한이 없습니다" + case let .backendError(message): "댓글 처리 실패: \(message)" + case let .serverError(code): "서버 오류 (코드: \(code))" + case let .unknown(message): "알 수 없는 오류: \(message)" + } + } +} + +public extension CommentError { + static func from(_ error: Error) -> CommentError { + if let commentError = error as? CommentError { return commentError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Domain/Entity/Sources/Error/PerspectiveError.swift b/Projects/Domain/Entity/Sources/Error/PerspectiveError.swift new file mode 100644 index 00000000..a99556f9 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/PerspectiveError.swift @@ -0,0 +1,35 @@ +// +// PerspectiveError.swift +// Entity +// + +import Foundation + +public enum PerspectiveError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): return "네트워크 오류: \(message)" + case let .decodingError(message): return "데이터 파싱 오류: \(message)" + case .noData: return "perspective 데이터가 없습니다" + case .unauthorized: return "권한이 없습니다" + case let .backendError(message): return "perspective 처리 실패: \(message)" + case let .serverError(code): return "서버 오류 (코드: \(code))" + case let .unknown(message): return "알 수 없는 오류: \(message)" + } + } +} + +public extension PerspectiveError { + static func from(_ error: Error) -> PerspectiveError { + if let perspectiveError = error as? PerspectiveError { return perspectiveError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Domain/Entity/Sources/Home/BattleDetail.swift b/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift similarity index 99% rename from Projects/Domain/Entity/Sources/Home/BattleDetail.swift rename to Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift index f5c26c71..bd45191f 100644 --- a/Projects/Domain/Entity/Sources/Home/BattleDetail.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift @@ -123,6 +123,7 @@ public enum BattleStep: String, Equatable, Hashable, CaseIterable { case listening = "LISTENING" case postVote = "POST_VOTE" case finished = "FINISHED" + case completed = "COMPLETED" case unknown public init(rawValue: String) { diff --git a/Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift b/Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift new file mode 100644 index 00000000..71d5fe9b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift @@ -0,0 +1,58 @@ +// +// BattleVoteStats.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/vote-stats` 응답 도메인 모델. +// 댓글 화면 상단의 옵션별 비율 막대 / 참여자 수 표시에 사용. +// + +import Foundation + +public struct BattleVoteStats: Equatable { + public let options: [BattleVoteStatsOption] + public let totalCount: Int + public let updatedAt: Date? + + public init( + options: [BattleVoteStatsOption], + totalCount: Int, + updatedAt: Date? + ) { + self.options = options + self.totalCount = totalCount + self.updatedAt = updatedAt + } +} + +public struct BattleVoteStatsOption: Equatable, Identifiable, Hashable { + public let optionId: Int + public let label: String? + public let title: String + public let isCorrect: Bool + public let voteCount: Int + public let ratio: Double + public let stance: String + public let imageUrl: String? + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String?, + title: String, + isCorrect: Bool, + voteCount: Int, + ratio: Double, + stance: String, + imageUrl: String? + ) { + self.optionId = optionId + self.label = label + self.title = title + self.isCorrect = isCorrect + self.voteCount = voteCount + self.ratio = ratio + self.stance = stance + self.imageUrl = imageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/PreVoteBattle.swift rename to Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/PreVoteResult.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift similarity index 88% rename from Projects/Domain/Entity/Sources/Home/PreVoteResult.swift rename to Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift index 3e4baf12..5744485f 100644 --- a/Projects/Domain/Entity/Sources/Home/PreVoteResult.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift @@ -9,7 +9,10 @@ public struct PreVoteResult: Equatable, Hashable { public let voteId: Int public let status: PreVoteResultStatus - public init(voteId: Int, status: PreVoteResultStatus) { + public init( + voteId: Int, + status: PreVoteResultStatus + ) { self.voteId = voteId self.status = status } diff --git a/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift new file mode 100644 index 00000000..bbd9c55c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift @@ -0,0 +1,94 @@ +// +// RecommendedBattle.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/recommendations/interesting` 응답 도메인 모델. +// 특정 배틀 기준 흥미로운 배틀 추천 목록 + 커서 페이지네이션. +// + +import Foundation + +public struct RecommendedBattlePage: Equatable { + public let items: [RecommendedBattle] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [RecommendedBattle], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} + +public struct RecommendedBattle: Equatable, Identifiable, Hashable { + public let battleId: Int + public let title: String + public let summary: String + public let audioDuration: Int + public let viewCount: Int + public let tags: [RecommendedBattleTag] + public let participantsCount: Int + public let options: [RecommendedBattleOption] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + summary: String, + audioDuration: Int, + viewCount: Int, + tags: [RecommendedBattleTag], + participantsCount: Int, + options: [RecommendedBattleOption] + ) { + self.battleId = battleId + self.title = title + self.summary = summary + self.audioDuration = audioDuration + self.viewCount = viewCount + self.tags = tags + self.participantsCount = participantsCount + self.options = options + } +} + +public struct RecommendedBattleTag: Equatable, Hashable, Identifiable { + public let tagId: Int + public let name: String + + public var id: Int { tagId } + + public init(tagId: Int, name: String) { + self.tagId = tagId + self.name = name + } +} + +public struct RecommendedBattleOption: Equatable, Hashable, Identifiable { + public let optionId: Int + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String? + + public var id: Int { optionId } + + public init( + optionId: Int, + title: String, + stance: String, + representative: String, + imageUrl: String? + ) { + self.optionId = optionId + self.title = title + self.stance = stance + self.representative = representative + self.imageUrl = imageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift new file mode 100644 index 00000000..187e1360 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift @@ -0,0 +1,103 @@ +// +// VoteSummary.swift +// Entity +// +// 댓글 화면 상단 투표 통계 모델. +// + +import Foundation + +public struct VoteSummary: Equatable { + public var changeBadgeTitle: String + public var optionA: VoteOptionSummary + public var optionB: VoteOptionSummary + + public init( + changeBadgeTitle: String, + optionA: VoteOptionSummary, + optionB: VoteOptionSummary + ) { + self.changeBadgeTitle = changeBadgeTitle + self.optionA = optionA + self.optionB = optionB + } + + public static let mock = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "변기는 변기다", representative: "플라톤", percentage: 0.595), + optionB: .init(label: "B", title: "예술이다", representative: "사르트르", percentage: 0.405) + ) + + public static let empty = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "", representative: "", percentage: 0), + optionB: .init(label: "B", title: "", representative: "", percentage: 0) + ) +} + +public struct VoteOptionSummary: Equatable { + public var optionId: Int + public var label: String + public var title: String + public var representative: String + public var imageUrl: String? + public var percentage: Double + + public init( + optionId: Int = 0, + label: String, + title: String, + representative: String, + imageUrl: String? = nil, + percentage: Double + ) { + self.optionId = optionId + self.label = label + self.title = title + self.representative = representative + self.imageUrl = imageUrl + self.percentage = percentage + } +} + +public enum CommentFilter: String, CaseIterable, Equatable { + case all + case optionA + case optionB + + public var title: String { + switch self { + case .all: "전체" + case .optionA: "A" + case .optionB: "B" + } + } + + /// 서버 쿼리에 보낼 optionLabel — `all` 은 nil. + public var queryLabel: String? { + switch self { + case .all: nil + case .optionA: "A" + case .optionB: "B" + } + } +} + +public enum CommentSort: String, CaseIterable, Equatable { + case popular + case latest + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } + + public var perspectiveSort: BattlePerspectiveSort { + switch self { + case .popular: .popular + case .latest: .latest + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift b/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift new file mode 100644 index 00000000..c3fd6a1e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift @@ -0,0 +1,115 @@ +// +// BattlePerspective.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/perspectives` 응답 도메인 모델. +// 댓글(=관점) 리스트 + 커서 페이지네이션. +// + +import Foundation + +public struct BattlePerspectivePage: Equatable { + public let items: [BattlePerspective] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [BattlePerspective], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} + +public struct BattlePerspective: Equatable, Identifiable, Hashable { + public let perspectiveId: Int + public let user: BattlePerspectiveUser + public let option: BattlePerspectiveOption + public let content: String + public let likeCount: Int + public let commentCount: Int + public let isLiked: Bool + public let isMyPerspective: Bool + public let createdAt: Date? + + public var id: Int { perspectiveId } + + public init( + perspectiveId: Int, + user: BattlePerspectiveUser, + option: BattlePerspectiveOption, + content: String, + likeCount: Int, + commentCount: Int, + isLiked: Bool, + isMyPerspective: Bool, + createdAt: Date? + ) { + self.perspectiveId = perspectiveId + self.user = user + self.option = option + self.content = content + self.likeCount = likeCount + self.commentCount = commentCount + self.isLiked = isLiked + self.isMyPerspective = isMyPerspective + self.createdAt = createdAt + } +} + +public struct BattlePerspectiveUser: Equatable, Hashable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageUrl: String? + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageUrl: String? + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageUrl = characterImageUrl + } +} + +public struct BattlePerspectiveOption: Equatable, Hashable, Identifiable { + public let optionId: Int + public let label: String? + public let title: String + public let stance: String + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String?, + title: String, + stance: String + ) { + self.optionId = optionId + self.label = label + self.title = title + self.stance = stance + } +} + +public enum BattlePerspectiveSort: String, Equatable, Hashable, CaseIterable { + case popular + case latest + + public var queryValue: String { rawValue } + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift new file mode 100644 index 00000000..f5e51924 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift @@ -0,0 +1,286 @@ +// +// Comment.swift +// Entity +// +// picke.pen `댓글화면` (j3GDzL) 매핑 도메인 모델. +// + +import Foundation + +public enum CommentOption: Equatable { + case a + case b + + public var label: String { + switch self { + case .a: "A" + case .b: "B" + } + } +} + +public struct CommentAuthor: Equatable, Hashable { + public let name: String + public let imageURL: String? + public let optionLabel: String? + + public init( + name: String, + imageURL: String? = nil, + optionLabel: String? = nil + ) { + self.name = name + self.imageURL = imageURL + self.optionLabel = optionLabel + } +} + +public struct Comment: Equatable, Identifiable, Hashable { + public let id: Int + public let author: CommentAuthor + public let text: String + public let createdAt: Date + public let likeCount: Int + public let replyCount: Int + public let isLiked: Bool + + public init( + id: Int, + author: CommentAuthor, + text: String, + createdAt: Date, + likeCount: Int, + replyCount: Int, + isLiked: Bool + ) { + self.id = id + self.author = author + self.text = text + self.createdAt = createdAt + self.likeCount = likeCount + self.replyCount = replyCount + self.isLiked = isLiked + } +} + +public struct CommentReplyItem: Equatable, Identifiable { + public let id: UUID + public var commentId: Int? + public var author: String + public var authorImageURL: String? + public var timeAgo: String + public var option: CommentOption + public var content: String + public var likeCount: Int + public var isLiked: Bool + public var isMine: Bool + public var createdOrder: Int + + public init( + id: UUID = UUID(), + commentId: Int? = nil, + author: String, + authorImageURL: String? = nil, + timeAgo: String, + option: CommentOption, + content: String, + likeCount: Int, + isLiked: Bool = false, + isMine: Bool = false, + createdOrder: Int + ) { + self.id = id + self.commentId = commentId + self.author = author + self.authorImageURL = authorImageURL + self.timeAgo = timeAgo + self.option = option + self.content = content + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdOrder = createdOrder + } + + /// 서버 PerspectiveComment 응답을 화면 모델로 변환. + public init( + item: PerspectiveComment, + parentOption: CommentOption, + order: Int + ) { + let id = UUID(uuidString: Self.deterministicUUID(commentId: item.commentId)) ?? UUID() + self.init( + id: id, + commentId: item.commentId, + author: item.user.nickname, + authorImageURL: item.user.characterImageUrl, + timeAgo: Self.relativeTimeString(from: item.createdAt), + option: parentOption, + content: item.content, + likeCount: item.likeCount, + isLiked: item.isLiked, + isMine: item.isMine, + createdOrder: order + ) + } + + private static func deterministicUUID(commentId: Int) -> String { + let hex = String(format: "%012X", commentId) + return "00000000-0000-0000-0002-\(hex)" + } + + private static func relativeTimeString(from date: Date?) -> String { + guard let date else { return "방금 전" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } + + public static func mocks(for option: CommentOption) -> [CommentReplyItem] { + [ + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000001") ?? UUID(), + author: "사색하는 사슴", + timeAgo: "2분 전", + option: option, + content: "네덜란드 사례를 일반화하기엔 무리가 있지 않나요? 한국의 사회문화적 맥락은 다릅니다.", + likeCount: 1340, + createdOrder: 3 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000002") ?? UUID(), + author: "논쟁하는 사자", + timeAgo: "2분 전", + option: option, + content: "제도 자체보다 사각지대를 줄이는 보완책을 같이 봐야 한다고 생각해요.", + likeCount: 534, + createdOrder: 2 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000003") ?? UUID(), + author: "질문하는 독자", + timeAgo: "5분 전", + option: option == .a ? .b : .a, + content: "반대 입장도 이해되지만, 개인의 자기결정권을 완전히 배제하기는 어렵지 않을까요?", + likeCount: 219, + createdOrder: 1 + ), + ] + } +} + +public enum CommentSortType: String, Equatable, Hashable, CaseIterable { + case popular = "POPULAR" + case latest = "LATEST" + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } +} + +public enum CommentTab: Equatable, Hashable { + case all + case option(label: String, title: String) + + public var title: String { + switch self { + case .all: "전체" + case let .option(_, title): title + } + } + + public var optionLabel: String? { + switch self { + case .all: nil + case let .option(label, _): label + } + } +} + +public struct CommentItem: Equatable, Identifiable { + public let id: UUID + public var perspectiveId: Int? + public var author: String + public var authorImageURL: String? + public var timeAgo: String + public var option: CommentOption + public var optionLabel: String? + public var content: String + public var replyCount: Int + public var likeCount: Int + public var isLiked: Bool + public var isMine: Bool + public var createdOrder: Int + + public init( + id: UUID = UUID(), + perspectiveId: Int? = nil, + author: String, + authorImageURL: String? = nil, + timeAgo: String, + option: CommentOption, + optionLabel: String? = nil, + content: String, + replyCount: Int, + likeCount: Int, + isLiked: Bool = false, + isMine: Bool = false, + createdOrder: Int + ) { + self.id = id + self.perspectiveId = perspectiveId + self.author = author + self.authorImageURL = authorImageURL + self.timeAgo = timeAgo + self.option = option + self.optionLabel = optionLabel + self.content = content + self.replyCount = replyCount + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdOrder = createdOrder + } + + /// API 응답 BattlePerspective 를 화면 모델로 변환. + public init( + item: BattlePerspective, + order: Int + ) { + let optionFallback: CommentOption = item.option.label == "B" ? .b : .a + self.init( + id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), + perspectiveId: item.perspectiveId, + author: item.user.nickname, + authorImageURL: item.user.characterImageUrl, + timeAgo: Self.relativeTimeString(from: item.createdAt), + option: optionFallback, + optionLabel: item.option.title, + content: item.content, + replyCount: item.commentCount, + likeCount: item.likeCount, + isLiked: item.isLiked, + isMine: item.isMyPerspective, + createdOrder: order + ) + } + + private static func deterministicUUID(perspectiveId: Int) -> String { + let hex = String(format: "%012X", perspectiveId) + return "00000000-0000-0000-0000-\(hex)" + } + + private static func relativeTimeString(from date: Date?) -> String { + guard let date else { return "방금 전" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift b/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift new file mode 100644 index 00000000..b3439e03 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift @@ -0,0 +1,24 @@ +// +// CommentLikeResult.swift +// Entity +// +// `POST/DELETE /api/v1/comments/{commentId}/likes` 응답. +// + +import Foundation + +public struct CommentLikeResult: Equatable, Hashable { + public let perspectiveId: Int + public let likeCount: Int + public let isLiked: Bool + + public init( + perspectiveId: Int, + likeCount: Int, + isLiked: Bool + ) { + self.perspectiveId = perspectiveId + self.likeCount = likeCount + self.isLiked = isLiked + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift new file mode 100644 index 00000000..6943d639 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift @@ -0,0 +1,93 @@ +// +// PerspectiveComment.swift +// Entity +// +// `GET /api/v1/perspectives/{perspectiveId}/comments/labeled` 등 대댓글 API 도메인 모델. +// + +import Foundation + +public struct PerspectiveCommentPage: Equatable { + public let items: [PerspectiveComment] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [PerspectiveComment], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} + +public struct PerspectiveComment: Equatable, Identifiable, Hashable { + public let commentId: Int + public let user: PerspectiveCommentUser + public let stance: String + public let content: String + public let likeCount: Int + public let isLiked: Bool + public let isMine: Bool + public let createdAt: Date? + + public var id: Int { commentId } + + public init( + commentId: Int, + user: PerspectiveCommentUser, + stance: String, + content: String, + likeCount: Int, + isLiked: Bool, + isMine: Bool, + createdAt: Date? + ) { + self.commentId = commentId + self.user = user + self.stance = stance + self.content = content + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdAt = createdAt + } +} + +public struct PerspectiveCommentUser: Equatable, Hashable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageUrl: String? + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageUrl: String? + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageUrl = characterImageUrl + } +} + +/// 대댓글 작성/수정 응답. +public struct PerspectiveCommentMutationResult: Equatable, Hashable { + public let commentId: Int + public let content: String + public let updatedAt: Date? + + public init( + commentId: Int, + content: String, + updatedAt: Date? + ) { + self.commentId = commentId + self.content = content + self.updatedAt = updatedAt + } +} diff --git a/Projects/Domain/Entity/Sources/Home/BattleTag.swift b/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift similarity index 93% rename from Projects/Domain/Entity/Sources/Home/BattleTag.swift rename to Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift index f5850030..92dd94ec 100644 --- a/Projects/Domain/Entity/Sources/Home/BattleTag.swift +++ b/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift @@ -15,7 +15,11 @@ public struct BattleTag: Equatable, Identifiable, Hashable { public var id: Int { tagId } - public init(tagId: Int, name: String, type: TagType) { + public init( + tagId: Int, + name: String, + type: TagType + ) { self.tagId = tagId self.name = name self.type = type diff --git a/Projects/Domain/Entity/Sources/Home/HomeBundle.swift b/Projects/Domain/Entity/Sources/Home/Core/HomeBundle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/HomeBundle.swift rename to Projects/Domain/Entity/Sources/Home/Core/HomeBundle.swift diff --git a/Projects/Domain/Entity/Sources/Home/BattleScenario.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift similarity index 95% rename from Projects/Domain/Entity/Sources/Home/BattleScenario.swift rename to Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift index 1c22953c..55d100e9 100644 --- a/Projects/Domain/Entity/Sources/Home/BattleScenario.swift +++ b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift @@ -48,7 +48,12 @@ public struct ScenarioPhilosopher: Equatable, Hashable, Identifiable { public var id: String { label } - public init(label: String, name: String, stance: String, imageUrl: String) { + public init( + label: String, + name: String, + stance: String, + imageUrl: String + ) { self.label = label self.name = name self.stance = stance @@ -113,7 +118,10 @@ public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { public var id: String { "\(label)-\(nextNodeId)" } - public init(label: String, nextNodeId: Int) { + public init( + label: String, + nextNodeId: Int + ) { self.label = label self.nextNodeId = nextNodeId } diff --git a/Projects/Domain/Entity/Sources/Home/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/ChatMessage.swift rename to Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift diff --git a/Projects/Domain/Entity/Sources/Home/BestBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/BestBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/BestBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/BestBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/HeroBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/HeroBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/HeroBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/HeroBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/HotBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/HotBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/HotBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/HotBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/NewBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/NewBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/NewBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/NewBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/QuizQuestion.swift b/Projects/Domain/Entity/Sources/Home/Section/QuizQuestion.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/QuizQuestion.swift rename to Projects/Domain/Entity/Sources/Home/Section/QuizQuestion.swift diff --git a/Projects/Domain/Entity/Sources/Home/VoteQuestion.swift b/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift similarity index 96% rename from Projects/Domain/Entity/Sources/Home/VoteQuestion.swift rename to Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift index 94520f66..be4ae3b2 100644 --- a/Projects/Domain/Entity/Sources/Home/VoteQuestion.swift +++ b/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift @@ -45,7 +45,10 @@ public struct VoteOption: Equatable, Identifiable, Hashable { public var id: String { label } - public init(label: String, title: String) { + public init( + label: String, + title: String + ) { self.label = label self.title = title } diff --git a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift new file mode 100644 index 00000000..30476cb9 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift @@ -0,0 +1,92 @@ +// +// BattleUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct BattleUseCaseImpl: BattleInterface { + @Dependency(\.battleRepository) private var battleRepository + + public init() {} + + public func fetchBattle(battleId: Int) async throws -> BattleDetail { + return try await battleRepository.fetchBattle(battleId: battleId) + } + + public func submitPreVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { + return try await battleRepository.submitPreVote(battleId: battleId, optionId: optionId) + } + + public func submitPostVote( + battleId: Int, + optionId: Int + ) async throws -> PreVoteResult { + return try await battleRepository.submitPostVote(battleId: battleId, optionId: optionId) + } + + public func fetchScenario(battleId: Int) async throws -> BattleScenario { + return try await battleRepository.fetchScenario(battleId: battleId) + } + + public func fetchVoteStats(battleId: Int) async throws -> BattleVoteStats { + return try await battleRepository.fetchVoteStats(battleId: battleId) + } + + public func fetchPerspectives( + battleId: Int, + cursor: String?, + size: Int?, + optionId: Int?, + sort: BattlePerspectiveSort? + ) async throws -> BattlePerspectivePage { + return try await battleRepository.fetchPerspectives( + battleId: battleId, + cursor: cursor, + size: size, + optionId: optionId, + sort: sort + ) + } + + public func createPerspective( + battleId: Int, + content: String, + optionId: Int? + ) async throws -> BattlePerspective { + return try await battleRepository.createPerspective( + battleId: battleId, + content: content, + optionId: optionId + ) + } + + public func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? { + return try await battleRepository.fetchMyPerspective(battleId: battleId) + } + + public func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage { + return try await battleRepository.fetchRecommendedBattles(battleId: battleId) + } +} + +extension BattleUseCaseImpl: DependencyKey { + public static var liveValue = BattleUseCaseImpl() + public static var testValue = BattleUseCaseImpl() + public static var previewValue = BattleUseCaseImpl() +} + +public extension DependencyValues { + var battleUseCase: BattleUseCaseImpl { + get { self[BattleUseCaseImpl.self] } + set { self[BattleUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Comment/CommentUseCase.swift b/Projects/Domain/UseCase/Sources/Comment/CommentUseCase.swift new file mode 100644 index 00000000..859f9f9d --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Comment/CommentUseCase.swift @@ -0,0 +1,38 @@ +// +// CommentUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct CommentUseCaseImpl: CommentInterface { + @Dependency(\.commentRepository) private var commentRepository + + public init() {} + + public func likeComment(commentId: Int) async throws -> CommentLikeResult { + return try await commentRepository.likeComment(commentId: commentId) + } + + public func unlikeComment(commentId: Int) async throws -> CommentLikeResult { + return try await commentRepository.unlikeComment(commentId: commentId) + } +} + +extension CommentUseCaseImpl: DependencyKey { + public static var liveValue = CommentUseCaseImpl() + public static var testValue = CommentUseCaseImpl() + public static var previewValue = CommentUseCaseImpl() +} + +public extension DependencyValues { + var commentUseCase: CommentUseCaseImpl { + get { self[CommentUseCaseImpl.self] } + set { self[CommentUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Home/HomeUseCase.swift b/Projects/Domain/UseCase/Sources/Home/HomeUseCase.swift new file mode 100644 index 00000000..bd23ed7e --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Home/HomeUseCase.swift @@ -0,0 +1,34 @@ +// +// HomeUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct HomeUseCaseImpl: HomeInterface { + @Dependency(\.homeRepository) private var homeRepository + + public init() {} + + public func fetchHome() async throws -> HomeBundle { + return try await homeRepository.fetchHome() + } +} + +extension HomeUseCaseImpl: DependencyKey { + public static var liveValue = HomeUseCaseImpl() + public static var testValue = HomeUseCaseImpl() + public static var previewValue = HomeUseCaseImpl() +} + +public extension DependencyValues { + var homeUseCase: HomeUseCaseImpl { + get { self[HomeUseCaseImpl.self] } + set { self[HomeUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift b/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift index 8655a6e1..c81f7203 100644 --- a/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift +++ b/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift @@ -24,7 +24,10 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { self.service = service } - public func save(accessToken: String, refreshToken: String) { + public func save( + accessToken: String, + refreshToken: String + ) { saveAccessToken(accessToken) saveRefreshToken(refreshToken) } @@ -54,7 +57,10 @@ public final class KeychainManager: KeychainManaging, @unchecked Sendable { delete(for: Key.refreshToken) } - private func save(_ value: String, for key: String) { + private func save( + _ value: String, + for key: String + ) { let data = Data(value.utf8) let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, diff --git a/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift new file mode 100644 index 00000000..c7fbccc0 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Perspective/PerspectiveUseCase.swift @@ -0,0 +1,100 @@ +// +// PerspectiveUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct PerspectiveUseCaseImpl: PerspectiveInterface { + @Dependency(\.perspectiveRepository) private var perspectiveRepository + + public init() {} + + public func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + return try await perspectiveRepository.fetchPerspective(perspectiveId: perspectiveId) + } + + public func fetchLabeledComments( + perspectiveId: Int, + cursor: String?, + size: Int? + ) async throws -> PerspectiveCommentPage { + return try await perspectiveRepository.fetchLabeledComments( + perspectiveId: perspectiveId, + cursor: cursor, + size: size + ) + } + + public func createComment( + perspectiveId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + return try await perspectiveRepository.createComment(perspectiveId: perspectiveId, content: content) + } + + public func updateComment( + perspectiveId: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + return try await perspectiveRepository.updateComment( + perspectiveId: perspectiveId, + commentId: commentId, + content: content + ) + } + + public func deleteComment( + perspectiveId: Int, + commentId: Int + ) async throws { + return try await perspectiveRepository.deleteComment(perspectiveId: perspectiveId, commentId: commentId) + } + + public func updatePerspective(perspectiveId: Int, content: String) async throws { + return try await perspectiveRepository.updatePerspective(perspectiveId: perspectiveId, content: content) + } + + public func deletePerspective(perspectiveId: Int) async throws { + return try await perspectiveRepository.deletePerspective(perspectiveId: perspectiveId) + } + + public func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + return try await perspectiveRepository.likePerspective(perspectiveId: perspectiveId) + } + + public func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + return try await perspectiveRepository.unlikePerspective(perspectiveId: perspectiveId) + } + + public func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { + return try await perspectiveRepository.fetchPerspectiveLikes(perspectiveId: perspectiveId) + } + + public func reportPerspective(perspectiveId: Int) async throws { + return try await perspectiveRepository.reportPerspective(perspectiveId: perspectiveId) + } + + public func reportComment(perspectiveId: Int, commentId: Int) async throws { + return try await perspectiveRepository.reportComment(perspectiveId: perspectiveId, commentId: commentId) + } +} + +extension PerspectiveUseCaseImpl: DependencyKey { + public static var liveValue = PerspectiveUseCaseImpl() + public static var testValue = PerspectiveUseCaseImpl() + public static var previewValue = PerspectiveUseCaseImpl() +} + +public extension DependencyValues { + var perspectiveUseCase: PerspectiveUseCaseImpl { + get { self[PerspectiveUseCaseImpl.self] } + set { self[PerspectiveUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift index b6e00550..de816b75 100644 --- a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift +++ b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift @@ -57,7 +57,10 @@ public struct AuthCoordinator { case presentMainTab } - func handleRoute(state: inout State, action: Action) -> Effect { + func handleRoute( + state: inout State, + action: Action + ) -> Effect { switch action { case let .router(routeAction): routerAction(state: &state, action: routeAction) diff --git a/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift b/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift index 0a6382f3..c8f59280 100644 --- a/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift +++ b/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift @@ -20,7 +20,12 @@ public struct OnBoardingFeature { public let subtitle: String public let imageAsset: ImageAsset - public init(id: Int, title: String, subtitle: String, imageAsset: ImageAsset) { + public init( + id: Int, + title: String, + subtitle: String, + imageAsset: ImageAsset + ) { self.id = id self.title = title self.subtitle = subtitle diff --git a/Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift b/Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift index 46b372b5..bde78cdb 100644 --- a/Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift +++ b/Projects/Presentation/Auth/Sources/OnBoarding/View/Components/OnBoardingPageIndicator.swift @@ -14,7 +14,10 @@ public struct OnBoardingPageIndicator: View { private let pageCount: Int private let currentIndex: Int - public init(pageCount: Int, currentIndex: Int) { + public init( + pageCount: Int, + currentIndex: Int + ) { self.pageCount = pageCount self.currentIndex = currentIndex } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 6b28d2ee..0a78c8a2 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -12,6 +12,7 @@ import DesignSystem import DomainInterface import Entity import LogMacro +import UseCase @Reducer public struct ChatRoomFeature { @@ -26,13 +27,19 @@ public struct ChatRoomFeature { public var playerDuration: TimeInterval = 0 public var battleId: Int = 0 public var isLoadingScenario: Bool = false - /// 한 번 끝까지 재생되어야 시킹(드래그) 허용 + /// 오디오 로딩 실패 시 상단 floating 오류 배너 노출 여부. + public var hasAudioError: Bool = false + /// 한 번 끝까지 재생된 콘텐츠는 이후 재진입 시 시킹/건너뛰기를 허용한다. public var hasFinishedListening: Bool = false public var hasPresentedFinalVoteAlert: Bool = false @Presents public var customAlert: CustomAlertState? /// 현재 재생 중인 시나리오 노드 id (없으면 startNodeId 폴백) public var currentNodeId: Int? + /// 현재 타임라인에서 화면에 노출된 노드들. nextNodeId/autoNextNodeId 를 따라 누적된다. + public var visibleNodeIds: [Int] = [] + /// 인터랙티브 노드 끝에 도달해 사용자의 입장 선택을 기다리는 상태. + public var isWaitingForNodeSelection: Bool = false /// 선택지 영역에서 사용자가 탭한 옵션 label public var selectedOptionLabel: String? @@ -47,7 +54,8 @@ public struct ChatRoomFeature { public var messages: [ChatMessage] { guard let scenario else { return bundle.messages } - return scenario.nodes.flatMap { node in + let nodes = visibleNodes(in: scenario) + return nodes.flatMap { node in node.scripts.map { script in ChatMessage( messageId: Self.scriptUUID(scriptId: script.scriptId), @@ -86,7 +94,10 @@ public struct ChatRoomFeature { public var canScrub: Bool { hasFinishedListening } - private func speaker(for script: ScenarioScript, in scenario: BattleScenario) -> ChatSpeaker { + 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) @@ -132,7 +143,8 @@ public struct ChatRoomFeature { } public var interactiveOptions: [ScenarioInteractiveOption] { - currentNode?.interactiveOptions ?? [] + guard isWaitingForNodeSelection else { return [] } + return currentNode?.interactiveOptions ?? [] } public var visibleOptions: [ScenarioInteractiveOption] { @@ -140,13 +152,39 @@ public struct ChatRoomFeature { } public var shouldShowOptions: Bool { - !visibleOptions.isEmpty + isWaitingForNodeSelection && !visibleOptions.isEmpty } public var isConfirmEnabled: Bool { selectedOptionLabel != nil } public init(battleId: Int = 0) { self.battleId = battleId + hasFinishedListening = Self.hasListenedBefore(battleId: battleId) + } + + private static func hasListenedBefore(battleId: Int) -> Bool { + UserDefaults.standard.bool(forKey: listenedKey(battleId: battleId)) + } + + fileprivate static func listenedKey(battleId: Int) -> String { + "picke.chatRoom.hasFinishedListening.\(battleId)" + } + + public func visibleNodes(in scenario: BattleScenario) -> [ScenarioNode] { + let ids = visibleNodeIds.isEmpty ? [currentNodeId ?? scenario.startNodeId] : visibleNodeIds + return ids.compactMap { id in + scenario.nodes.first { $0.nodeId == id } + } + } + + public func nodeStartTime(for nodeId: Int) -> TimeInterval { + guard let node = scenario?.nodes.first(where: { $0.nodeId == nodeId }) else { return 0 } + return TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 + } + + public func nodeEndTime(for node: ScenarioNode) -> TimeInterval { + let start = TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 + return start + TimeInterval(node.audioDuration) } } @@ -162,6 +200,7 @@ public struct ChatRoomFeature { @CasePathable public enum View { case onAppear + case onDisappear case backButtonTapped case refreshTapped case togglePlayTapped @@ -179,9 +218,11 @@ public struct ChatRoomFeature { } public enum InnerAction: Equatable { - case scenarioResponse(Result) + case scenarioResponse(Result) case playerTimeUpdated(TimeInterval) case playerDurationUpdated(TimeInterval) + case audioLoadFailed + case dismissAudioError } @CasePathable @@ -191,14 +232,16 @@ public struct ChatRoomFeature { public enum DelegateAction: Equatable { case dismiss + case requestFinalVote(battleId: Int) } nonisolated enum CancelID: Hashable { case fetchScenario case audioObserver + case audioErrorDismiss } - @Dependency(\.battleRepository) private var battleRepository + @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.audioPlayer) private var audioPlayer public var body: some Reducer { @@ -238,6 +281,13 @@ extension ChatRoomFeature { ? subscribe.merge(with: .send(.async(.fetchScenario))) : subscribe + case .onDisappear: + state.isPlaying = false + return .merge( + .cancel(id: CancelID.audioObserver), + .run { [player = audioPlayer] _ in await player.pause() } + ) + case .backButtonTapped: return .run { [player = audioPlayer] send in await player.pause() @@ -247,6 +297,10 @@ extension ChatRoomFeature { case .refreshTapped: state.currentTime = 0 state.isPlaying = false + state.currentNodeId = state.scenario?.startNodeId + state.visibleNodeIds = state.scenario.map { [$0.startNodeId] } ?? [] + state.isWaitingForNodeSelection = false + state.selectedOptionLabel = nil return .run { [player = audioPlayer] _ in await player.pause() await player.seek(to: 0) @@ -292,27 +346,34 @@ extension ChatRoomFeature { let option = state.visibleOptions.first(where: { $0.label == label }) else { return .none } state.currentNodeId = option.nextNodeId + if !state.visibleNodeIds.contains(option.nextNodeId) { + state.visibleNodeIds.append(option.nextNodeId) + } state.selectedOptionLabel = nil - state.currentTime = 0 - state.hasFinishedListening = false - state.isPlaying = false + state.isWaitingForNodeSelection = false + let targetTime = state.nodeStartTime(for: option.nextNodeId) + state.currentTime = targetTime + state.isPlaying = true return .run { [player = audioPlayer] _ in - await player.pause() - await player.seek(to: 0) + await player.seek(to: targetTime) + await player.play() } } } - private func handleAsyncAction(state: inout State, action: AsyncAction) -> Effect { + 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 + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.fetchScenario(battleId: battleId) } - .mapError(AuthError.from) + .mapError(BattleError.from) return await send(.inner(.scenarioResponse(result))) } .cancellable(id: CancelID.fetchScenario, cancelInFlight: true) @@ -321,8 +382,13 @@ extension ChatRoomFeature { state.currentTime = 0 state.playerDuration = 0 state.isPlaying = true + state.hasAudioError = false return .run { [player = audioPlayer] send in - await player.load(url: url) + let isPlayable = await player.load(url: url) + guard isPlayable else { + await send(.inner(.audioLoadFailed)) + return + } let duration = await player.duration() if duration > 0 { await send(.inner(.playerDurationUpdated(duration))) @@ -340,7 +406,10 @@ extension ChatRoomFeature { } } - private func handleInnerAction(state: inout State, action: InnerAction) -> Effect { + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { switch action { case let .scenarioResponse(result): state.isLoadingScenario = false @@ -350,6 +419,11 @@ extension ChatRoomFeature { if state.currentNodeId == nil { state.currentNodeId = scenario.startNodeId } + if state.visibleNodeIds.isEmpty { + state.visibleNodeIds = [scenario.startNodeId] + } + state.isWaitingForNodeSelection = false + state.selectedOptionLabel = nil if let urlString = scenario.audios[scenario.recommendedPathKey.rawValue] ?? scenario.audios.values.first, let url = URL(string: urlString) @@ -364,11 +438,16 @@ extension ChatRoomFeature { case let .playerTimeUpdated(time): state.currentTime = time + if let effect = advanceNodeIfNeeded(state: &state, time: time) { + return effect + } if state.totalDuration > 0, - time >= state.totalDuration - 0.5, - !state.hasFinishedListening + time >= state.totalDuration - 0.5 { - state.hasFinishedListening = true + if !state.hasFinishedListening { + state.hasFinishedListening = true + UserDefaults.standard.set(true, forKey: State.listenedKey(battleId: state.battleId)) + } state.isPlaying = false if !state.hasPresentedFinalVoteAlert { state.hasPresentedFinalVoteAlert = true @@ -380,10 +459,59 @@ extension ChatRoomFeature { case let .playerDurationUpdated(duration): state.playerDuration = duration return .none + + case .audioLoadFailed: + state.isPlaying = false + state.hasAudioError = true + // 3초 후 자동으로 배너 숨김. + return .run { send in + try? await Task.sleep(for: .seconds(3)) + await send(.inner(.dismissAudioError)) + } + .cancellable(id: CancelID.audioErrorDismiss, cancelInFlight: true) + + case .dismissAudioError: + state.hasAudioError = false + return .none + } + } + + private func advanceNodeIfNeeded( + state: inout State, + time: TimeInterval + ) -> Effect? { + guard let scenario = state.scenario, + let currentNode = state.currentNode + else { return nil } + + let nodeEndTime = state.nodeEndTime(for: currentNode) + guard time >= nodeEndTime - 0.25 else { return nil } + + if !currentNode.interactiveOptions.isEmpty { + guard !state.isWaitingForNodeSelection else { return nil } + state.isWaitingForNodeSelection = true + state.isPlaying = false + return .run { [player = audioPlayer] _ in + await player.pause() + } } + + guard let nextNodeId = currentNode.autoNextNodeId, + scenario.nodes.contains(where: { $0.nodeId == nextNodeId }), + state.currentNodeId != nextNodeId + else { return nil } + + state.currentNodeId = nextNodeId + if !state.visibleNodeIds.contains(nextNodeId) { + state.visibleNodeIds.append(nextNodeId) + } + return .none } - private func handleScopeAction(state: inout State, action: ScopeAction) -> Effect { + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { switch action { case let .customAlert(alertAction): switch alertAction { @@ -391,7 +519,11 @@ extension ChatRoomFeature { switch customAlertAction { case .confirmTapped: state.customAlert = nil - return .none + let battleId = state.battleId + return .run { [player = audioPlayer] send in + await player.pause() + await send(.delegate(.requestFinalVote(battleId: battleId))) + } case .cancelTapped: state.customAlert = nil state.currentTime = 0 @@ -408,9 +540,12 @@ extension ChatRoomFeature { } } - private func handleDelegateAction(state _: inout State, action: DelegateAction) -> Effect { + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { switch action { - case .dismiss: + case .dismiss, .requestFinalVote: .none } } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift index 1ad25712..d8828992 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift @@ -17,10 +17,13 @@ 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 + + private enum Metric { + static let bubbleMaxWidth: CGFloat = 222 + static let avatarSize: CGFloat = 32.7 + static let avatarImageWidth: CGFloat = 24 + static let avatarImageHeight: CGFloat = 28 + } public init(store: StoreOf) { self.store = store @@ -42,10 +45,20 @@ public struct ChatRoomView: View { } } .background(Color.beige200.ignoresSafeArea()) + .overlay(alignment: .top) { + if store.hasAudioError { + FloatingErrorView(message: "오디오를 불러오는 중 문제가 발생했어요") + .padding(.top, 64) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) + } + } + .animation(.easeInOut(duration: 0.25), value: store.hasAudioError) .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .tabBar) .onAppear { send(.onAppear) } + .onDisappear { send(.onDisappear) } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) } @@ -166,17 +179,20 @@ extension ChatRoomView { private func avatar(_ speaker: ChatSpeaker) -> some View { KFImage(URL(string: speaker.imageURL ?? "")) .placeholder { - SkeletonView(cornerRadius: Self.avatarSize / 2) + SkeletonView(cornerRadius: Metric.avatarSize / 2) } .resizable() .scaledToFit() - .frame(width: Self.avatarImageWidth, height: Self.avatarImageHeight) - .frame(width: Self.avatarSize, height: Self.avatarSize) + .frame(width: Metric.avatarImageWidth, height: Metric.avatarImageHeight) + .frame(width: Metric.avatarSize, height: Metric.avatarSize) .background(.beige600, in: Circle()) } @ViewBuilder - private func bubbleColumn(speaker: ChatSpeaker, messages: [ChatMessage]) -> some View { + 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) @@ -189,18 +205,21 @@ extension ChatRoomView { } } } - .frame(maxWidth: Self.bubbleMaxWidth, alignment: speaker.side == .left ? .leading : .trailing) + .frame(maxWidth: Metric.bubbleMaxWidth, alignment: speaker.side == .left ? .leading : .trailing) } @ViewBuilder - private func bubble(text: String, side: ChatSpeakerSide) -> some View { + 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) + .frame(maxWidth: Metric.bubbleMaxWidth, alignment: .leading) .background( side == .left ? Color.beige50 : Color.beige400, in: RoundedRectangle(cornerRadius: 2) diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift new file mode 100644 index 00000000..5b62d1e4 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -0,0 +1,621 @@ +// +// CommentFeature.swift +// Chat +// +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import DomainInterface +import Entity +import LogMacro +import UseCase + +@Reducer +public struct CommentFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var battleId: Int = 0 + public var perspectiveId: Int? + /// 관점 등록 시 사용할 내 옵션(투표한 진영) optionId. + public var myOptionId: Int? + public var title: String = "" + public var voteSummary: VoteSummary = .mock + public var isLoadingStats: Bool = false + public var isLoadingComments: Bool = false + public var isSubmitting: Bool = false + public var nextCursor: String? + public var hasNext: Bool = false + public var selectedFilter: CommentFilter = .all + public var selectedSort: CommentSort = .popular + public var reportTargetCommentID: UUID? + /// "…" 메뉴(수정/삭제 또는 신고)를 띄울 대상 댓글 id. + public var menuTargetCommentID: UUID? + /// 수정 중인 관점 perspectiveId (입력창이 수정 모드). + public var editingPerspectiveId: Int? + /// 삭제 확인 알럿의 대상 perspectiveId. + public var deleteTargetPerspectiveId: Int? + @Presents public var customAlert: CustomAlertState? + + /// 메뉴 대상 댓글(있으면 confirmationDialog 표시). + public var menuTargetComment: CommentItem? { + guard let id = menuTargetCommentID else { return nil } + return comments.first { $0.id == id } + } + + public var comments: [CommentItem] = [] + public var commentText: String = "" + + public var filteredComments: [CommentItem] { + switch selectedFilter { + case .all: + comments + case .optionA: + comments.filter { $0.option == .a } + case .optionB: + comments.filter { $0.option == .b } + } + } + + public var isSendEnabled: Bool { + !commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isSubmitting + } + + 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 forwardTapped + case shareTapped + case filterTapped(CommentFilter) + case sortTapped(CommentSort) + case reportPopupDismissed + case menuDismissed + case commentMenu(id: UUID, action: CommentMenuAction) + case commentRow(id: UUID, action: CommentRowAction) + case sendTapped + } + + public enum CommentMenuAction: Equatable { + case more + case edit + case delete + case report + } + + public enum CommentRowAction: Equatable { + case openReply + case reportConfirm + case like + } + + public enum AsyncAction: Equatable { + case fetchBattle + case fetchMyPerspective + case fetchVoteStats + case fetchPerspectives(reset: Bool) + case toggleLike(commentId: Int, currentlyLiked: Bool) + case createComment(content: String) + case updatePerspective(perspectiveId: Int, content: String) + case deletePerspective(perspectiveId: Int) + case reportPerspective(perspectiveId: Int) + case fetchPerspectiveLikes(perspectiveId: Int) + } + + public enum InnerAction: Equatable { + case battleResponse(Result) + case myPerspectiveResponse(Result) + case voteStatsResponse(Result) + case perspectivesResponse(Result, reset: Bool) + case likeResponse(Result) + case createCommentResponse(Result) + case mutationFinished + case perspectiveLikesResponse(Result) + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + public enum DelegateAction: Equatable { + case dismiss + case openReply(CommentItem) + case openCuration(battleId: Int) + } + + nonisolated enum CancelID: Hashable { + case fetchBattle + case fetchMyPerspective + case fetchVoteStats + case fetchPerspectives + case toggleLike + case createComment + } + + @Dependency(\.battleUseCase) private var battleUseCase + @Dependency(\.commentUseCase) private var commentUseCase + @Dependency(\.perspectiveUseCase) private var perspectiveUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.commentText): + if state.commentText.count > 200 { + state.commentText = String(state.commentText.prefix(200)) + } + return .none + + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case .delegate: + return .none + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension CommentFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .merge( + .send(.async(.fetchBattle)), + .send(.async(.fetchMyPerspective)), + .send(.async(.fetchVoteStats)), + .send(.async(.fetchPerspectives(reset: true))) + ) + + case .backButtonTapped: + return .send(.delegate(.dismiss)) + + case .forwardTapped: + // 앱바 우측 > : 큐레이팅(흥미 기반 추천 배틀) 화면으로 이동 + return .send(.delegate(.openCuration(battleId: state.battleId))) + + case .shareTapped: + return .none + + case let .commentRow(id, action): + switch action { + case .openReply: + guard let comment = state.comments.first(where: { $0.id == id }) else { return .none } + state.reportTargetCommentID = nil + return .send(.delegate(.openReply(comment))) + case .reportConfirm: + state.reportTargetCommentID = nil + guard let comment = state.comments.first(where: { $0.id == id }), + let pid = comment.perspectiveId + else { return .none } + return .send(.async(.reportPerspective(perspectiveId: pid))) + case .like: + guard let index = state.comments.firstIndex(where: { $0.id == id }), + let commentId = state.comments[index].perspectiveId + else { return .none } + let wasLiked = state.comments[index].isLiked + state.comments[index].isLiked.toggle() + state.comments[index].likeCount += state.comments[index].isLiked ? 1 : -1 + return .send(.async(.toggleLike(commentId: commentId, currentlyLiked: wasLiked))) + } + + case .menuDismissed: + state.menuTargetCommentID = nil + return .none + + case let .commentMenu(id, action): + switch action { + case .more: + // "…" 탭 → 해당 댓글 바로 아래 메뉴 토글 (내 글: 수정/삭제, 남 글: 신고) + state.menuTargetCommentID = (state.menuTargetCommentID == id) ? nil : id + case .edit: + state.menuTargetCommentID = nil + guard let comment = state.comments.first(where: { $0.id == id }), + let pid = comment.perspectiveId + else { return .none } + state.editingPerspectiveId = pid + state.commentText = comment.content + case .delete: + state.menuTargetCommentID = nil + guard let comment = state.comments.first(where: { $0.id == id }), + let pid = comment.perspectiveId + else { return .none } + state.deleteTargetPerspectiveId = pid + state.customAlert = .deletePerspective() + case .report: + state.menuTargetCommentID = nil + state.reportTargetCommentID = id + state.customAlert = .report() + } + return .none + + case .reportPopupDismissed: + state.reportTargetCommentID = nil + state.customAlert = nil + return .none + + case let .filterTapped(filter): + state.selectedFilter = filter + state.reportTargetCommentID = nil + return .send(.async(.fetchPerspectives(reset: true))) + + case let .sortTapped(sort): + state.selectedSort = sort + state.reportTargetCommentID = nil + return .send(.async(.fetchPerspectives(reset: true))) + + case .sendTapped: + let text = state.commentText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !state.isSubmitting else { return .none } + state.isSubmitting = true + state.commentText = "" + if let editId = state.editingPerspectiveId { + state.editingPerspectiveId = nil + return .send(.async(.updatePerspective(perspectiveId: editId, content: text))) + } + return .send(.async(.createComment(content: text))) + } + } + + 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 + if let pid = state.deleteTargetPerspectiveId { + state.deleteTargetPerspectiveId = nil + return .send(.async(.deletePerspective(perspectiveId: pid))) + } + if let id = state.reportTargetCommentID { + return .send(.view(.commentRow(id: id, action: .reportConfirm))) + } + return .none + + case .cancelTapped: + state.reportTargetCommentID = nil + state.deleteTargetPerspectiveId = nil + state.customAlert = nil + return .none + } + + case .dismiss: + state.reportTargetCommentID = nil + state.deleteTargetPerspectiveId = nil + state.customAlert = nil + return .none + } + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchBattle: + let battleId = state.battleId + return .run { [repository = battleUseCase] send in + let result = await Result { + try await repository.fetchBattle(battleId: battleId) + } + .mapError(BattleError.from) + return await send(.inner(.battleResponse(result))) + } + .cancellable(id: CancelID.fetchBattle, cancelInFlight: true) + + case .fetchMyPerspective: + let battleId = state.battleId + return .run { [repository = battleUseCase] send in + let result = await Result { + try await repository.fetchMyPerspective(battleId: battleId) + } + .mapError(BattleError.from) + return await send(.inner(.myPerspectiveResponse(result))) + } + .cancellable(id: CancelID.fetchMyPerspective, cancelInFlight: true) + + case .fetchVoteStats: + state.isLoadingStats = true + let battleId = state.battleId + return .run { [repository = battleUseCase] send in + let result = await Result { + try await repository.fetchVoteStats(battleId: battleId) + } + .mapError(BattleError.from) + return await send(.inner(.voteStatsResponse(result))) + } + .cancellable(id: CancelID.fetchVoteStats, cancelInFlight: true) + + case let .fetchPerspectives(reset): + state.isLoadingComments = true + // 초기/정렬/필터/등록 후 reset 시 리스트를 비워 스켈레톤이 노출되도록 한다. + if reset { state.comments = [] } + let battleId = state.battleId + let cursor = reset ? nil : state.nextCursor + let optionId: Int? = { + switch state.selectedFilter { + case .all: return nil + case .optionA: return state.voteSummary.optionA.optionId > 0 ? state.voteSummary.optionA.optionId : nil + case .optionB: return state.voteSummary.optionB.optionId > 0 ? state.voteSummary.optionB.optionId : nil + } + }() + let sort = state.selectedSort.perspectiveSort + return .run { [repository = battleUseCase] send in + let result = await Result { + try await repository.fetchPerspectives( + battleId: battleId, + cursor: cursor, + size: 20, + optionId: optionId, + sort: sort + ) + } + .mapError(BattleError.from) + return await send(.inner(.perspectivesResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetchPerspectives, cancelInFlight: true) + + case let .toggleLike(commentId, currentlyLiked): + // commentId 는 관점(perspective) id. 관점 좋아요는 perspectives/{id}/likes 사용. + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + if currentlyLiked { + try await repository.unlikePerspective(perspectiveId: commentId) + } else { + try await repository.likePerspective(perspectiveId: commentId) + } + } + .mapError(CommentError.from) + return await send(.inner(.likeResponse(result))) + } + .cancellable(id: CancelID.toggleLike, cancelInFlight: false) + + case let .createComment(content): + let battleId = state.battleId + // 등록 진영: 현재 선택된 필터 탭의 옵션. 전체 탭이면 내 투표 진영(myOptionId). + let optionId: Int? = { + switch state.selectedFilter { + case .all: return state.myOptionId + case .optionA: return state.voteSummary.optionA.optionId > 0 ? state.voteSummary.optionA.optionId : state + .myOptionId + case .optionB: return state.voteSummary.optionB.optionId > 0 ? state.voteSummary.optionB.optionId : state + .myOptionId + } + }() + // 관점 등록: POST /battles/{id}/perspectives, body {content, optionId} + return .run { [battle = battleUseCase] send in + let result = await Result { + try await battle.createPerspective(battleId: battleId, content: content, optionId: optionId) + } + .mapError(BattleError.from) + return await send(.inner(.createCommentResponse(result))) + } + .cancellable(id: CancelID.createComment, cancelInFlight: false) + + case let .updatePerspective(perspectiveId, content): + return .run { [repository = perspectiveUseCase] send in + try? await repository.updatePerspective(perspectiveId: perspectiveId, content: content) + await send(.inner(.mutationFinished)) + } + + case let .deletePerspective(perspectiveId): + return .run { [repository = perspectiveUseCase] send in + try? await repository.deletePerspective(perspectiveId: perspectiveId) + await send(.inner(.mutationFinished)) + } + + case let .reportPerspective(perspectiveId): + return .run { [repository = perspectiveUseCase] _ in + try? await repository.reportPerspective(perspectiveId: perspectiveId) + } + + case let .fetchPerspectiveLikes(perspectiveId): + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.fetchPerspectiveLikes(perspectiveId: perspectiveId) + } + .mapError(CommentError.from) + return await send(.inner(.perspectiveLikesResponse(result))) + } + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .battleResponse(result): + switch result { + case let .success(detail): + state.title = detail.battleInfo.title + // 필터 탭/아바타에 쓸 옵션 title·대표를 배틀 상세에서 채운다. + // (vote-stats 가 비어 있어도 실제 A/B title 이 노출되도록) + let options = detail.battleInfo.options + if options.count >= 2 { + state.voteSummary.optionA.optionId = options[0].optionId + state.voteSummary.optionA.title = options[0].title + state.voteSummary.optionA.representative = options[0].representative + state.voteSummary.optionA.imageUrl = options[0].imageUrl + state.voteSummary.optionB.optionId = options[1].optionId + state.voteSummary.optionB.title = options[1].title + state.voteSummary.optionB.representative = options[1].representative + state.voteSummary.optionB.imageUrl = options[1].imageUrl + } + // 내 관점이 아직 없으면 투표 진영(userVoteStatus)으로 등록 optionId 결정. + if state.myOptionId == nil, options.count >= 2 { + switch detail.userVoteStatus { + case .pro: state.myOptionId = options[0].optionId + case .con: state.myOptionId = options[1].optionId + default: break + } + } + case let .failure(error): + Log.error("[CommentFeature] fetchBattle failed: \(error.localizedDescription)") + } + return .none + + case let .myPerspectiveResponse(result): + switch result { + case let .success(perspective): + state.perspectiveId = perspective?.perspectiveId + if let optionId = perspective?.option.optionId { state.myOptionId = optionId } + // 내 perspectiveId 가 늦게 로드돼도 기존 목록에서 내 글을 표시. + if let myPid = perspective?.perspectiveId { + for index in state.comments.indices where state.comments[index].perspectiveId == myPid { + state.comments[index].isMine = true + } + } + case let .failure(error): + Log.error("[CommentFeature] fetchMyPerspective failed: \(error.localizedDescription)") + } + return .none + + case let .voteStatsResponse(result): + state.isLoadingStats = false + switch result { + case let .success(stats): + state.voteSummary = makeSummary(from: stats, fallback: state.voteSummary) + case let .failure(error): + Log.error("[CommentFeature] fetchVoteStats failed: \(error.localizedDescription)") + } + return .none + + case let .perspectivesResponse(result, reset): + state.isLoadingComments = false + switch result { + case let .success(page): + let myPid = state.perspectiveId + let mapped = page.items.enumerated().map { idx, item -> CommentItem in + var comment = CommentItem(item: item, order: idx) + // 서버 isMyPerspective 가 누락/false 여도 내 perspectiveId 와 일치하면 내 글로 판정. + if let myPid, comment.perspectiveId == myPid { + comment.isMine = true + } + return comment + } + state.comments = reset ? mapped : state.comments + mapped + state.nextCursor = page.nextCursor + state.hasNext = page.hasNext + // 표시되는 각 관점의 좋아요 수를 GET /perspectives/{id}/likes 로 조회. + let likeEffects: [Effect] = mapped.compactMap { item in + item.perspectiveId.map { .send(.async(.fetchPerspectiveLikes(perspectiveId: $0))) } + } + return likeEffects.isEmpty ? .none : .merge(likeEffects) + case let .failure(error): + Log.error("[CommentFeature] fetchPerspectives failed: \(error.localizedDescription)") + } + return .none + + case let .likeResponse(result): + switch result { + case let .success(payload): + if let index = state.comments.firstIndex(where: { $0.perspectiveId == payload.perspectiveId }) { + state.comments[index].likeCount = payload.likeCount + state.comments[index].isLiked = payload.isLiked + } + case let .failure(error): + Log.error("[CommentFeature] toggleLike failed: \(error.localizedDescription)") + } + return .none + + case let .createCommentResponse(result): + state.isSubmitting = false + switch result { + case .success: + return .send(.async(.fetchPerspectives(reset: true))) + case let .failure(error): + Log.error("[CommentFeature] createComment failed: \(error.localizedDescription)") + return .none + } + + case .mutationFinished: + // 관점 수정/삭제 완료 → 입력 상태 복구 + 목록 갱신 + state.isSubmitting = false + return .send(.async(.fetchPerspectives(reset: true))) + + case let .perspectiveLikesResponse(result): + if case let .success(payload) = result, + let index = state.comments.firstIndex(where: { $0.perspectiveId == payload.perspectiveId }) + { + // GET 은 좋아요 "수" 조회 용도. isLiked 는 목록 응답 값을 신뢰하고 덮어쓰지 않는다. + state.comments[index].likeCount = payload.likeCount + } + return .none + } + } + + /// API 응답(BattleVoteStats) 을 화면 모델 VoteSummary 로 매핑. + /// 옵션이 2개 이상이라 가정 — 부족하거나 매핑 실패 시 fallback 유지. + private func makeSummary( + from stats: BattleVoteStats, + fallback: VoteSummary + ) -> VoteSummary { + guard stats.options.count >= 2 else { return fallback } + let a = stats.options[0] + let b = stats.options[1] + return VoteSummary( + changeBadgeTitle: fallback.changeBadgeTitle, + optionA: VoteOptionSummary( + optionId: a.optionId, + label: a.label ?? fallback.optionA.label, + title: a.title.isEmpty ? fallback.optionA.title : a.title, + representative: fallback.optionA.representative, + imageUrl: a.imageUrl ?? fallback.optionA.imageUrl, + percentage: a.ratio + ), + optionB: VoteOptionSummary( + optionId: b.optionId, + label: b.label ?? fallback.optionB.label, + title: b.title.isEmpty ? fallback.optionB.title : b.title, + representative: fallback.optionB.representative, + imageUrl: b.imageUrl ?? fallback.optionB.imageUrl, + percentage: b.ratio + ) + ) + } +} diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift new file mode 100644 index 00000000..c499766c --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -0,0 +1,613 @@ +// +// CommentView.swift +// Chat +// +// .pen `댓글화면` 기준 mock 댓글 UI. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: CommentFeature.self) +public struct CommentView: View { + @Bindable public var store: StoreOf + @FocusState private var isCommentFocused: Bool + @State private var hasAnimatedVoteProgress = false + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + navigationBar() + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + summarySection() + filterSection() + commentList() + } + .padding(.bottom, 16) + } + .scrollDismissesKeyboard(.interactively) + .simultaneousGesture( + DragGesture(minimumDistance: 24) + .onEnded { value in + // 좌우 스와이프로 필터 탭(전체/옵션A/옵션B) 전환 + guard abs(value.translation.width) > abs(value.translation.height), + abs(value.translation.width) > 50, + let next = adjacentFilter(forward: value.translation.width < 0) + else { return } + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + send(.filterTapped(next)) + } + } + ) + inputBar() + } + .background(Color.beige200.ignoresSafeArea()) + .contentShape(Rectangle()) + .onTapGesture { + isCommentFocused = false + } + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { + send(.onAppear) + withAnimation(.easeOut(duration: 0.75).delay(0.15)) { + hasAnimatedVoteProgress = true + } + } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } + + /// 댓글 카드 바로 아래에 뜨는 인라인 메뉴 (pill 형태). + @ViewBuilder + private func inlineMenu(for comment: CommentItem) -> some View { + VStack(alignment: .trailing, spacing: 8) { + ForEach(menuItems(for: comment)) { item in + Button { item.action() } label: { + HStack(spacing: 4) { + Image(systemName: item.systemImage) + .font(.system(size: 13, weight: .medium)) + Text(item.title) + .pretendardFont(family: .Medium, size: 13) + } + .foregroundStyle(Color.beige50) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.primary500, in: Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(.trailing, 4) + } + + /// "…" 메뉴 항목 — 내 글: 수정/삭제, 남 글: 신고. + private func menuItems(for comment: CommentItem) -> [BottomActionItem] { + if comment.isMine { + return [ + BottomActionItem(title: "수정", systemImage: "pencil") { send(.commentMenu(id: comment.id, action: .edit)) }, + BottomActionItem(title: "삭제", systemImage: "trash", isDestructive: true) { send(.commentMenu( + id: comment.id, + action: .delete + )) }, + ] + } else { + return [ + BottomActionItem(title: "신고", systemImage: "light.beacon.max.fill", isDestructive: true) { + send(.commentMenu(id: comment.id, action: .report)) + }, + ] + } + } + + /// 현재 선택된 필터 기준 인접 필터(스와이프 방향). 범위를 벗어나면 nil. + private func adjacentFilter(forward: Bool) -> CommentFilter? { + let all = CommentFilter.allCases + guard let idx = all.firstIndex(of: store.selectedFilter) else { return nil } + let nextIdx = forward ? idx + 1 : idx - 1 + guard all.indices.contains(nextIdx) else { return nil } + return all[nextIdx] + } +} + +// MARK: - Navigation + +private extension CommentView { + @ViewBuilder + func navigationBar() -> some View { + HStack { + Button { send(.backButtonTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + + Spacer() + + Text(store.title) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.neutral500) + .lineLimit(1) + + Spacer() + + Button { send(.forwardTapped) } label: { + Image(systemName: "chevron.right") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .foregroundStyle(.neutral500) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Summary + +private extension CommentView { + @ViewBuilder + func summarySection() -> some View { + VStack(spacing: 12) { + changeBadge() + + HStack(alignment: .center, spacing: 12) { + HStack(spacing: 4) { + avatarCircle( + imageUrl: store.voteSummary.optionA.imageUrl, + name: store.voteSummary.optionA.representative + ) + Text(percentText(store.voteSummary.optionA.percentage)) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral500) + .fixedSize() + } + voteProgress() + HStack(spacing: 4) { + Text(percentText(store.voteSummary.optionB.percentage)) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral500) + .fixedSize() + avatarCircle( + imageUrl: store.voteSummary.optionB.imageUrl, + name: store.voteSummary.optionB.representative + ) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(.beige50) + } + + @ViewBuilder + func changeBadge() -> some View { + HStack(spacing: 4) { + Image(systemName: "lightbulb") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.primary500) + Text(store.voteSummary.changeBadgeTitle) + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.primary500) + } + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) + .frame(maxWidth: .infinity, alignment: .center) + } + + @ViewBuilder + func voteProgress() -> some View { + GeometryReader { proxy in + let leftWidth = proxy.size.width * store.voteSummary.optionA.percentage + ZStack(alignment: .leading) { + Capsule() + .fill(.beige600) + .frame(height: 6) + Capsule() + .fill(.primary500) + .frame(width: hasAnimatedVoteProgress ? max(0, leftWidth) : 0, height: 6) + } + .frame(maxHeight: .infinity) + .animation(.easeOut(duration: 0.75), value: store.voteSummary.optionA.percentage) + } + .frame(height: 6) + } + + @ViewBuilder + func avatarCircle(imageUrl: String?, name: String) -> some View { + CommentAvatarView( + imageURL: imageUrl, + fallback: name, + size: 40 + ) + } +} + +// MARK: - Filters + +private extension CommentView { + @ViewBuilder + func filterSection() -> some View { + VStack(spacing: 12) { + filterTabs() + sortRow() + } + .padding(.top, 14) + .padding(.bottom, 12) + } + + @ViewBuilder + func filterTabs() -> some View { + HStack(spacing: 0) { + ForEach(CommentFilter.allCases, id: \.self) { filter in + filterButton(filter) + } + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + func sortRow() -> some View { + HStack(spacing: 8) { + sortButton(.popular) + sortButton(.latest) + Spacer() + } + .padding(.horizontal, 16) + } + + @ViewBuilder + func filterButton(_ filter: CommentFilter) -> some View { + let isSelected = store.selectedFilter == filter + let title: String = switch filter { + case .all: "전체" + case .optionA: store.voteSummary.optionA.title + case .optionB: store.voteSummary.optionB.title + } + Button { + send(.filterTapped(filter)) + } label: { + Text(title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .primary500 : .neutral300) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(isSelected ? Color.primary500 : Color.neutral200) + .frame(height: isSelected ? 2.5 : 1) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + func sortButton(_ sort: CommentSort) -> some View { + let isSelected = store.selectedSort == sort + Button { + send(.sortTapped(sort)) + } label: { + Text(sort.title) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(isSelected ? .beige50 : .primary500) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + isSelected ? Color.primary500 : Color.primary50, + in: RoundedRectangle(cornerRadius: 2) + ) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(Color.primary500, lineWidth: isSelected ? 0 : 1) + } + } + .buttonStyle(.plain) + } +} + +// MARK: - Comment List + +private extension CommentView { + @ViewBuilder + func commentList() -> some View { + Group { + if store.isLoadingComments, store.comments.isEmpty { + CommentSkeletonView() + } else if store.comments.isEmpty { + emptyState() + } else { + VStack(spacing: 12) { + ForEach(store.comments) { comment in + commentCard(comment) + .overlay(alignment: .topTrailing) { + if store.menuTargetCommentID == comment.id { + inlineMenu(for: comment) + .padding(.trailing, 12) + .offset(y: 46) + .zIndex(1) + } + } + .zIndex(store.menuTargetCommentID == comment.id ? 1 : 0) + } + } + .animation(.easeInOut(duration: 0.18), value: store.menuTargetCommentID) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + + @ViewBuilder + func emptyState() -> some View { + VStack(spacing: 8) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 32, weight: .light)) + .foregroundStyle(.neutral300) + Text("아직 등록된 의견이 없어요") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral400) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } + + @ViewBuilder + func commentCard(_ comment: CommentItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + commentHeader(comment) + commentBody(comment) + commentActions(comment) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + } + } + + @ViewBuilder + func commentBody(_ comment: CommentItem) -> some View { + Button { send(.commentRow(id: comment.id, action: .openReply)) } label: { + Text(comment.content) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func commentHeader(_ comment: CommentItem) -> some View { + HStack(alignment: .top, spacing: 6) { + avatar(urlString: comment.authorImageURL, fallback: comment.author) + commentAuthorBlock(comment) + Spacer() + reportButton(commentId: comment.id) + } + } + + @ViewBuilder + func commentAuthorBlock(_ comment: CommentItem) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(comment.isMine ? "나" : comment.author) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral500) + .lineLimit(1) + + if comment.isMine { + myBadge() + } + + Text(comment.timeAgo) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral300) + } + + optionBadge(comment) + } + } + + @ViewBuilder + func myBadge() -> some View { + Text("나") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.beige50) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.primary500, in: RoundedRectangle(cornerRadius: 2)) + } + + @ViewBuilder + func reportButton(commentId: UUID) -> some View { + Button { send(.commentMenu(id: commentId, action: .more)) } label: { + Image(systemName: "ellipsis") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .foregroundStyle(.neutral300) + } + + @ViewBuilder + func optionBadge(_ comment: CommentItem) -> some View { + let summary = comment.option == .a ? store.voteSummary.optionA : store.voteSummary.optionB + let label = comment.optionLabel ?? summary.title + Text(label) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.primary500) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + + @ViewBuilder + func commentActions(_ comment: CommentItem) -> some View { + HStack(spacing: 12) { + moreButton(commentId: comment.id) + Spacer() + replyCountButton(comment) + likeButton(comment) + } + .foregroundStyle(.neutral300) + } + + @ViewBuilder + func moreButton(commentId: UUID) -> some View { + Button { send(.commentRow(id: commentId, action: .openReply)) } label: { + Text("더보기") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral300) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func replyCountButton(_ comment: CommentItem) -> some View { + Button { send(.commentRow(id: comment.id, action: .openReply)) } label: { + actionLabel(systemName: "message", text: "\(comment.replyCount)") + } + .buttonStyle(.plain) + } + + @ViewBuilder + func likeButton(_ comment: CommentItem) -> some View { + Button { send(.commentRow(id: comment.id, action: .like)) } label: { + actionLabel( + systemName: comment.isLiked ? "heart.fill" : "heart", + text: formattedCount(comment.likeCount) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func actionLabel( + systemName: String, + text: String + ) -> some View { + HStack(spacing: 4) { + Image(systemName: systemName) + .font(.system(size: 14, weight: .medium)) + .frame(width: 16, height: 16) + Text(text) + .pretendardFont(family: .Medium, size: 12) + } + } + + @ViewBuilder + func avatar( + urlString: String?, + fallback: String + ) -> some View { + CommentAvatarView( + imageURL: urlString, + fallback: fallback, + size: 36 + ) + } +} + +// MARK: - Input + +private extension CommentView { + @ViewBuilder + func inputBar() -> some View { + VStack(spacing: 8) { + HStack(alignment: .bottom, spacing: 8) { + inputTextBox() + sendButton() + } + } + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 24) + .frame(height: 128) + .background(.beige400) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige800) + .frame(height: 1) + } + } + + @ViewBuilder + func inputTextBox() -> some View { + VStack(alignment: .leading, spacing: 6) { + TextField("댓글을 입력해주세요", text: $store.commentText, axis: .vertical) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineLimit(1 ... 3) + .focused($isCommentFocused) + + Text("\(store.commentText.count)/200") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.neutral400) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(.beige50) + } + + @ViewBuilder + func sendButton() -> some View { + Button { send(.sendTapped) } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.beige50) + .frame(width: 36, height: 36) + .background(store.isSendEnabled ? Color.primary500 : Color.primary200, in: Circle()) + } + .buttonStyle(.plain) + .disabled(!store.isSendEnabled) + } +} + +// MARK: - Format + +private extension CommentView { + func percentText(_ percentage: Double) -> String { + "\(String(format: "%.1f", percentage * 100))%" + } + + func formattedCount(_ count: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: count)) ?? "\(count)" + } +} + +#Preview { + CommentView( + store: Store(initialState: CommentFeature.State()) { + CommentFeature() + } + ) +} diff --git a/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift new file mode 100644 index 00000000..382caf9b --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentAvatarView.swift @@ -0,0 +1,60 @@ +// +// CommentAvatarView.swift +// Chat +// +// 댓글 프로필 이미지 공통 뷰. +// + +import SwiftUI + +import DesignSystem +import Kingfisher + +struct CommentAvatarView: View { + let imageURL: String? + let fallback: String + let size: CGFloat + let imageScale: CGFloat + + init( + imageURL: String?, + fallback: String, + size: CGFloat, + imageScale: CGFloat = 1.16 + ) { + self.imageURL = imageURL + self.fallback = fallback + self.size = size + self.imageScale = imageScale + } + + var body: some View { + ZStack { + Circle() + .fill(.beige600) + + avatarContent() + } + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().stroke(.beige600, lineWidth: 1)) + } +} + +private extension CommentAvatarView { + @ViewBuilder + func avatarContent() -> some View { + if let imageURL, let url = URL(string: imageURL) { + KFImage(url) + .placeholder { Color.beige600 } + .resizable() + .scaledToFill() + .frame(width: 24, height: 24) + .scaleEffect(imageScale) + } else { + Text(String(fallback.prefix(1))) + .pretendardFont(family: .SemiBold, size: size <= 36 ? 13 : 14) + .foregroundStyle(.primary500) + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift new file mode 100644 index 00000000..4aacc618 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift @@ -0,0 +1,46 @@ +// +// CommentSkeletonView.swift +// Chat +// +// 댓글(관점) 리스트 로딩 placeholder. +// 초기 로드 / 정렬·필터 전환 / 등록 후 갱신 시 노출된다. +// + +import SwiftUI + +import DesignSystem + +struct CommentSkeletonView: View { + var count: Int = 3 + + var body: some View { + VStack(spacing: 12) { + ForEach(0 ..< count, id: \.self) { _ in + card() + } + } + } + + @ViewBuilder + private func card() -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + SkeletonView(cornerRadius: 14) + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 4) { + SkeletonView(cornerRadius: 4).frame(width: 80, height: 12) + SkeletonView(cornerRadius: 4).frame(width: 48, height: 10) + } + Spacer() + } + SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 12) + SkeletonView(cornerRadius: 4).frame(width: 200, height: 12) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + } + } +} diff --git a/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift new file mode 100644 index 00000000..a7323a35 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -0,0 +1,574 @@ +// +// CommentReplyFeature.swift +// Chat +// +// 대댓글 화면. + +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import DomainInterface +import Entity +import LogMacro +import UseCase + +@Reducer +public struct CommentReplyFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var perspectiveId: Int + public var parentComment: CommentItem + public var replies: [CommentReplyItem] = [] + public var replyText: String = "" + public var isLoadingDetail: Bool = false + public var isLoadingReplies: Bool = false + public var nextCursor: String? + public var hasNext: Bool = false + public var editingCommentId: Int? + /// "…" 메뉴를 띄울 대상 답글 id. + public var menuTargetReplyId: UUID? + /// 부모(관점) "…" 메뉴 열림. + public var parentMenuOpen: Bool = false + /// 입력창이 부모 관점 수정 모드. + public var editingParent: Bool = false + /// 삭제 확인 알럿 대상 commentId. + public var deleteTargetCommentId: Int? + /// 신고 확인 알럿 대상 commentId. + public var reportTargetCommentId: Int? + /// 삭제/신고 알럿이 부모(관점) 대상. + public var deleteParentPending: Bool = false + public var reportParentPending: Bool = false + @Presents public var customAlert: CustomAlertState? + + public var menuTargetReply: CommentReplyItem? { + guard let id = menuTargetReplyId else { return nil } + return replies.first { $0.id == id } + } + + public var isSendEnabled: Bool { + !replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + public init( + perspectiveId: Int, + parentComment: CommentItem + ) { + self.perspectiveId = perspectiveId + self.parentComment = parentComment + } + } + + 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 parentLikeTapped + case replyLikeTapped(UUID) + case sendTapped + case beginEditing(commentId: Int, content: String) + case cancelEditing + case deleteTapped(commentId: Int) + case menuDismissed + case replyMenu(id: UUID, action: MenuAction) + case parentMenu(MenuAction) + } + + public enum MenuAction: Equatable { + case more + case edit + case delete + case report + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + public enum AsyncAction: Equatable { + case fetchParent + case fetchReplies(reset: Bool) + case createReply(content: String) + case updateReply(commentId: Int, content: String) + case deleteReply(commentId: Int) + case toggleParentLike(currentlyLiked: Bool) + case toggleReplyLike(commentId: Int, currentlyLiked: Bool) + case fetchParentLikes + case reportComment(commentId: Int) + case updateParent(content: String) + case deleteParent + case reportParent + } + + public enum InnerAction: Equatable { + case parentResponse(Result) + case repliesResponse(Result, reset: Bool) + case createResponse(Result) + case updateResponse(Result, commentId: Int) + case deleteResponse(Result) + case parentLikeResponse(Result) + case replyLikeResponse(Result) + case parentLikesResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + nonisolated enum CancelID: Hashable { + case fetchParent + case fetchReplies + case mutate + case like + } + + @Dependency(\.perspectiveUseCase) private var perspectiveUseCase + @Dependency(\.commentUseCase) private var commentUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.replyText): + if state.replyText.count > 200 { + state.replyText = String(state.replyText.prefix(200)) + } + return .none + + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case .delegate: + return .none + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } + + 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 + if state.deleteParentPending { + state.deleteParentPending = false + return .send(.async(.deleteParent)) + } + if state.reportParentPending { + state.reportParentPending = false + return .send(.async(.reportParent)) + } + if let commentId = state.deleteTargetCommentId { + state.deleteTargetCommentId = nil + return .send(.async(.deleteReply(commentId: commentId))) + } + if let commentId = state.reportTargetCommentId { + state.reportTargetCommentId = nil + return .send(.async(.reportComment(commentId: commentId))) + } + return .none + case .cancelTapped: + state.deleteTargetCommentId = nil + state.reportTargetCommentId = nil + state.deleteParentPending = false + state.reportParentPending = false + state.customAlert = nil + return .none + } + case .dismiss: + state.deleteTargetCommentId = nil + state.reportTargetCommentId = nil + state.deleteParentPending = false + state.reportParentPending = false + state.customAlert = nil + return .none + } + } + } +} + +// MARK: - View handler + +extension CommentReplyFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .merge( + .send(.async(.fetchParent)), + .send(.async(.fetchParentLikes)), + .send(.async(.fetchReplies(reset: true))) + ) + + case .backButtonTapped: + return .send(.delegate(.dismiss)) + + case .parentLikeTapped: + let wasLiked = state.parentComment.isLiked + state.parentComment.isLiked.toggle() + state.parentComment.likeCount += state.parentComment.isLiked ? 1 : -1 + return .send(.async(.toggleParentLike(currentlyLiked: wasLiked))) + + case let .replyLikeTapped(id): + guard let index = state.replies.firstIndex(where: { $0.id == id }), + let commentId = state.replies[index].commentId + else { return .none } + let wasLiked = state.replies[index].isLiked + state.replies[index].isLiked.toggle() + state.replies[index].likeCount += state.replies[index].isLiked ? 1 : -1 + return .send(.async(.toggleReplyLike(commentId: commentId, currentlyLiked: wasLiked))) + + case .sendTapped: + let text = state.replyText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return .none } + state.replyText = "" + if state.editingParent { + state.editingParent = false + return .send(.async(.updateParent(content: text))) + } + if let editingId = state.editingCommentId { + state.editingCommentId = nil + return .send(.async(.updateReply(commentId: editingId, content: text))) + } + return .send(.async(.createReply(content: text))) + + case let .beginEditing(commentId, content): + state.editingCommentId = commentId + state.replyText = content + return .none + + case .cancelEditing: + state.editingCommentId = nil + state.replyText = "" + return .none + + case let .deleteTapped(commentId): + return .send(.async(.deleteReply(commentId: commentId))) + + case .menuDismissed: + state.menuTargetReplyId = nil + return .none + + case let .replyMenu(id, action): + switch action { + case .more: + state.menuTargetReplyId = (state.menuTargetReplyId == id) ? nil : id + return .none + case .edit: + state.menuTargetReplyId = nil + guard let reply = state.replies.first(where: { $0.id == id }), + let commentId = reply.commentId + else { return .none } + state.editingCommentId = commentId + state.replyText = reply.content + return .none + case .delete: + state.menuTargetReplyId = nil + guard let reply = state.replies.first(where: { $0.id == id }), + let commentId = reply.commentId + else { return .none } + state.deleteTargetCommentId = commentId + state.customAlert = .deleteComment() + return .none + case .report: + state.menuTargetReplyId = nil + guard let reply = state.replies.first(where: { $0.id == id }), + let commentId = reply.commentId + else { return .none } + state.reportTargetCommentId = commentId + state.customAlert = .report() + return .none + } + + case let .parentMenu(action): + switch action { + case .more: + state.parentMenuOpen.toggle() + case .edit: + state.parentMenuOpen = false + state.editingCommentId = nil + state.editingParent = true + state.replyText = state.parentComment.content + case .delete: + state.parentMenuOpen = false + state.deleteParentPending = true + state.customAlert = .deletePerspective() + case .report: + state.parentMenuOpen = false + state.reportParentPending = true + state.customAlert = .report() + } + return .none + } + } +} + +// MARK: - Async handler + +extension CommentReplyFeature { + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchParent: + state.isLoadingDetail = true + let pid = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.fetchPerspective(perspectiveId: pid) + } + .mapError(PerspectiveError.from) + return await send(.inner(.parentResponse(result))) + } + .cancellable(id: CancelID.fetchParent, cancelInFlight: true) + + case let .fetchReplies(reset): + state.isLoadingReplies = true + // 갱신(reset) 시 리스트를 비워 스켈레톤이 노출되도록 한다. + if reset { state.replies = [] } + let pid = state.perspectiveId + let cursor = reset ? nil : state.nextCursor + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.fetchLabeledComments(perspectiveId: pid, cursor: cursor, size: 20) + } + .mapError(CommentError.from) + return await send(.inner(.repliesResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetchReplies, cancelInFlight: true) + + case let .createReply(content): + let pid = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.createComment(perspectiveId: pid, content: content) + } + .mapError(CommentError.from) + return await send(.inner(.createResponse(result))) + } + .cancellable(id: CancelID.mutate, cancelInFlight: false) + + case let .updateReply(commentId, content): + let pid = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.updateComment(perspectiveId: pid, commentId: commentId, content: content) + } + .mapError(CommentError.from) + return await send(.inner(.updateResponse(result, commentId: commentId))) + } + .cancellable(id: CancelID.mutate, cancelInFlight: false) + + case let .deleteReply(commentId): + let pid = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.deleteComment(perspectiveId: pid, commentId: commentId) + return commentId + } + .mapError(CommentError.from) + return await send(.inner(.deleteResponse(result))) + } + .cancellable(id: CancelID.mutate, cancelInFlight: false) + + case let .toggleParentLike(currentlyLiked): + // 부모는 관점(perspective) → perspectives/{id}/likes 사용. + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + if currentlyLiked { + try await repository.unlikePerspective(perspectiveId: perspectiveId) + } else { + try await repository.likePerspective(perspectiveId: perspectiveId) + } + } + .mapError(CommentError.from) + return await send(.inner(.parentLikeResponse(result))) + } + .cancellable(id: CancelID.like, cancelInFlight: false) + + case let .toggleReplyLike(commentId, currentlyLiked): + return .run { [repository = commentUseCase] send in + let result = await Result { + if currentlyLiked { + try await repository.unlikeComment(commentId: commentId) + } else { + try await repository.likeComment(commentId: commentId) + } + } + .mapError(CommentError.from) + return await send(.inner(.replyLikeResponse(result))) + } + .cancellable(id: CancelID.like, cancelInFlight: false) + + case .fetchParentLikes: + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.fetchPerspectiveLikes(perspectiveId: perspectiveId) + } + .mapError(CommentError.from) + return await send(.inner(.parentLikesResponse(result))) + } + + case let .reportComment(commentId): + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] _ in + try? await repository.reportComment(perspectiveId: perspectiveId, commentId: commentId) + } + + case let .updateParent(content): + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + try? await repository.updatePerspective(perspectiveId: perspectiveId, content: content) + await send(.async(.fetchParent)) + } + + case .deleteParent: + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] send in + try? await repository.deletePerspective(perspectiveId: perspectiveId) + await send(.delegate(.dismiss)) + } + + case .reportParent: + let perspectiveId = state.perspectiveId + return .run { [repository = perspectiveUseCase] _ in + try? await repository.reportPerspective(perspectiveId: perspectiveId) + } + } + } +} + +// MARK: - Inner handler + +extension CommentReplyFeature { + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .parentResponse(result): + state.isLoadingDetail = false + switch result { + case let .success(perspective): + state.parentComment = CommentItem(item: perspective, order: 0) + case let .failure(error): + Log.error("[CommentReplyFeature] fetchParent failed: \(error.localizedDescription)") + } + return .none + + case let .repliesResponse(result, reset): + state.isLoadingReplies = false + switch result { + case let .success(page): + let mapped = page.items.enumerated().map { idx, item in + CommentReplyItem(item: item, parentOption: state.parentComment.option, order: idx) + } + state.replies = reset ? mapped : state.replies + mapped + state.nextCursor = page.nextCursor + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[CommentReplyFeature] fetchReplies failed: \(error.localizedDescription)") + } + return .none + + case let .createResponse(result): + switch result { + case .success: + return .send(.async(.fetchReplies(reset: true))) + case let .failure(error): + Log.error("[CommentReplyFeature] createReply failed: \(error.localizedDescription)") + return .none + } + + case let .updateResponse(result, _): + switch result { + case .success: + // 수정 후 리스트 reset 갱신 → 스켈레톤 노출 + return .send(.async(.fetchReplies(reset: true))) + case let .failure(error): + Log.error("[CommentReplyFeature] updateReply failed: \(error.localizedDescription)") + return .none + } + + case let .deleteResponse(result): + switch result { + case let .success(commentId): + state.replies.removeAll { $0.commentId == commentId } + if state.parentComment.replyCount > 0 { state.parentComment.replyCount -= 1 } + case let .failure(error): + Log.error("[CommentReplyFeature] deleteReply failed: \(error.localizedDescription)") + } + return .none + + case let .parentLikeResponse(result): + switch result { + case let .success(payload): + state.parentComment.likeCount = payload.likeCount + state.parentComment.isLiked = payload.isLiked + case let .failure(error): + Log.error("[CommentReplyFeature] toggleParentLike failed: \(error.localizedDescription)") + } + return .none + + case let .replyLikeResponse(result): + switch result { + case let .success(payload): + if let index = state.replies.firstIndex(where: { $0.commentId == payload.perspectiveId }) { + state.replies[index].likeCount = payload.likeCount + state.replies[index].isLiked = payload.isLiked + } + case let .failure(error): + Log.error("[CommentReplyFeature] toggleReplyLike failed: \(error.localizedDescription)") + } + return .none + + case let .parentLikesResponse(result): + switch result { + case let .success(payload): + state.parentComment.likeCount = payload.likeCount + case let .failure(error): + Log.error("[CommentReplyFeature] fetchParentLikes failed: \(error.localizedDescription)") + } + return .none + } + } +} diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift new file mode 100644 index 00000000..fee3c001 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -0,0 +1,414 @@ +// +// CommentReplyView.swift +// Chat +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: CommentReplyFeature.self) +public struct CommentReplyView: View { + @Bindable public var store: StoreOf + @FocusState private var isReplyFocused: Bool + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + navigationBar() + ScrollView(showsIndicators: false) { + if store.isLoadingReplies, store.replies.isEmpty { + CommentReplySkeletonView() + } else { + VStack(spacing: 0) { + parentCommentSection() + replySection() + } + .padding(.bottom, 16) + } + } + .scrollDismissesKeyboard(.interactively) + inputBar() + } + .background(Color.beige200.ignoresSafeArea()) + .contentShape(Rectangle()) + .onTapGesture { + isReplyFocused = false + } + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + .animation(.easeInOut(duration: 0.18), value: store.menuTargetReplyId) + } + + /// 답글 "…" 메뉴 (내 글: 수정/삭제, 남 글: 신고) — pill 형태. + @ViewBuilder + private func replyMenu(for reply: CommentReplyItem) -> some View { + VStack(alignment: .trailing, spacing: 8) { + if reply.isMine { + menuPill(title: "수정", systemImage: "pencil") { send(.replyMenu(id: reply.id, action: .edit)) } + menuPill(title: "삭제", systemImage: "trash") { send(.replyMenu(id: reply.id, action: .delete)) } + } else { + menuPill(title: "신고", systemImage: "light.beacon.max.fill") { send(.replyMenu(id: reply.id, action: .report)) } + } + } + } + + /// 부모(관점) "…" 메뉴 (내 글: 수정/삭제, 남 글: 신고). + @ViewBuilder + private func parentMenuView() -> some View { + VStack(alignment: .trailing, spacing: 8) { + if store.parentComment.isMine { + menuPill(title: "수정", systemImage: "pencil") { send(.parentMenu(.edit)) } + menuPill(title: "삭제", systemImage: "trash") { send(.parentMenu(.delete)) } + } else { + menuPill(title: "신고", systemImage: "light.beacon.max.fill") { send(.parentMenu(.report)) } + } + } + } + + @ViewBuilder + private func menuPill( + title: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .medium)) + Text(title) + .pretendardFont(family: .Medium, size: 13) + } + .foregroundStyle(Color.beige50) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.primary500, in: Capsule()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Navigation + +private extension CommentReplyView { + @ViewBuilder + func navigationBar() -> some View { + HStack { + Button { send(.backButtonTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + + Spacer() + + Text("댓글") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.neutral500) + + Spacer() + + Color.clear.frame(width: 24, height: 24) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .foregroundStyle(.neutral500) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Content + +private extension CommentReplyView { + @ViewBuilder + func parentCommentSection() -> some View { + VStack(spacing: 0) { + commentCard( + author: store.parentComment.isMine ? "나" : store.parentComment.author, + imageURL: store.parentComment.authorImageURL, + timeAgo: store.parentComment.timeAgo, + option: store.parentComment.option, + optionLabel: store.parentComment.optionLabel ?? "", + content: store.parentComment.content, + replyCount: store.parentComment.replyCount, + likeCount: store.parentComment.likeCount, + isLiked: store.parentComment.isLiked, + isMine: store.parentComment.isMine, + background: .beige50, + showsReplyCount: true, + moreAction: { send(.parentMenu(.more)) }, + likeAction: { send(.parentLikeTapped) } + ) + .overlay(alignment: .topTrailing) { + if store.parentMenuOpen { + parentMenuView() + .padding(.trailing, 12) + .offset(y: 46) + .zIndex(1) + } + } + } + .padding(12) + .background(.beige50) + .zIndex(store.parentMenuOpen ? 1 : 0) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } + + @ViewBuilder + func replySection() -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("답글 \(store.replies.count)개") + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.neutral900) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.top, 12) + + ForEach(store.replies) { reply in + commentCard( + author: reply.isMine ? "나" : reply.author, + imageURL: reply.authorImageURL, + timeAgo: reply.timeAgo, + option: reply.option, + optionLabel: store.parentComment.optionLabel ?? "", + content: reply.content, + replyCount: nil, + likeCount: reply.likeCount, + isLiked: reply.isLiked, + isMine: reply.isMine, + background: .beige50, + showsReplyCount: false, + moreAction: { send(.replyMenu(id: reply.id, action: .more)) }, + likeAction: { send(.replyLikeTapped(reply.id)) } + ) + .overlay(alignment: .topTrailing) { + if store.menuTargetReplyId == reply.id { + replyMenu(for: reply) + .padding(.trailing, 24) + .offset(y: 46) + .zIndex(1) + } + } + .zIndex(store.menuTargetReplyId == reply.id ? 1 : 0) + } + } + .padding(.bottom, 24) + .background(.beige50) + } + + func commentCard( + author: String, + imageURL: String?, + timeAgo: String, + option: CommentOption, + optionLabel: String, + content: String, + replyCount: Int?, + likeCount: Int, + isLiked: Bool, + isMine: Bool, + background: Color, + showsReplyCount: Bool, + moreAction: (() -> Void)? = nil, + likeAction: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + commentHeader( + author: author, + imageURL: imageURL, + timeAgo: timeAgo, + option: option, + optionLabel: optionLabel, + isMine: isMine, + moreAction: moreAction + ) + + Text(content) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 2) + + HStack(spacing: 12) { + Spacer() + + if showsReplyCount, let replyCount { + actionLabel(systemName: "message", text: "\(replyCount)") + } + + Button(action: likeAction) { + actionLabel( + systemName: isLiked ? "heart.fill" : "heart", + text: formattedCount(likeCount) + ) + } + .buttonStyle(.plain) + } + .foregroundStyle(.neutral300) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(background, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + } + .padding(.horizontal, 12) + } + + func commentHeader( + author: String, + imageURL: String?, + timeAgo: String, + option: CommentOption, + optionLabel: String, + isMine: Bool, + moreAction: (() -> Void)? + ) -> some View { + HStack(alignment: .top, spacing: 8) { + CommentAvatarView( + imageURL: imageURL, + fallback: author, + size: 36 + ) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(author) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral500) + .lineLimit(1) + + if isMine { + myBadge() + } + + Text(timeAgo) + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.neutral300) + } + + optionBadge(label: optionLabel, option: option) + } + + Spacer() + + Button { moreAction?() } label: { + Image(systemName: "ellipsis") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(.neutral300) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + func myBadge() -> some View { + Text("나") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.beige50) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.primary500, in: RoundedRectangle(cornerRadius: 2)) + } + + func optionBadge(label: String, option: CommentOption) -> some View { + Text(label) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(option == .a ? .primary500 : .beige50) + .padding(.horizontal, option == .a ? 4 : 6) + .padding(.vertical, 2) + .background(option == .a ? .beige600 : .primary500, in: RoundedRectangle(cornerRadius: 2)) + } + + func actionLabel( + systemName: String, + text: String + ) -> some View { + HStack(spacing: 4) { + Image(systemName: systemName) + .font(.system(size: 14, weight: .medium)) + .frame(width: 16, height: 16) + + Text(text) + .pretendardFont(family: .Medium, size: 12) + } + } +} + +// MARK: - Input + +private extension CommentReplyView { + @ViewBuilder + func inputBar() -> some View { + HStack(alignment: .bottom, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { + TextField("내 의견은 어쩌구 저쩌구", text: $store.replyText, axis: .vertical) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineLimit(1 ... 3) + .focused($isReplyFocused) + + Text("\(store.replyText.count)/200") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.neutral400) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(.beige50) + + Button { send(.sendTapped) } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.beige50) + .frame(width: 36, height: 36) + .background(store.isSendEnabled ? Color.primary500 : Color.primary200, in: Circle()) + } + .buttonStyle(.plain) + .disabled(!store.isSendEnabled) + } + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 24) + .frame(height: 128) + .background(.beige400) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige800) + .frame(height: 1) + } + } +} + +// MARK: - Format + +private extension CommentReplyView { + func formattedCount(_ count: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: count)) ?? "\(count)" + } +} diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift new file mode 100644 index 00000000..08788065 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift @@ -0,0 +1,73 @@ +// +// CommentReplySkeletonView.swift +// Chat +// +// 대댓글 화면 로딩 placeholder (부모 댓글 + 답글 목록). +// + +import SwiftUI + +import DesignSystem + +struct CommentReplySkeletonView: View { + var replyCount: Int = 3 + + var body: some View { + VStack(spacing: 0) { + parentCard() + .padding(12) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + + VStack(alignment: .leading, spacing: 8) { + SkeletonView(cornerRadius: 4) + .frame(width: 64, height: 14) + .padding(.horizontal, 12) + .padding(.top, 12) + + ForEach(0 ..< replyCount, id: \.self) { _ in + card() + .padding(.horizontal, 12) + } + } + .padding(.bottom, 24) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + @ViewBuilder + private func parentCard() -> some View { + card() + } + + @ViewBuilder + private func card() -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + SkeletonView(cornerRadius: 18) + .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 4) { + SkeletonView(cornerRadius: 4).frame(width: 90, height: 14) + SkeletonView(cornerRadius: 4).frame(width: 56, height: 16) + } + Spacer() + } + SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 12) + SkeletonView(cornerRadius: 4).frame(width: 220, height: 12) + HStack { + Spacer() + SkeletonView(cornerRadius: 4).frame(width: 44, height: 14) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index 746a8e6b..8e2024b1 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -48,7 +48,10 @@ public struct ChatCoordinator { case dismiss } - func handleRoute(state: inout State, action: Action) -> Effect { + func handleRoute( + state: inout State, + action: Action + ) -> Effect { switch action { case let .router(routeAction): routerAction(state: &state, action: routeAction) @@ -71,13 +74,58 @@ extension ChatCoordinator { 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))) + case let .routeAction(_, action: .preVote(.delegate(.voteSubmitted(battleId, voteMode, _)))): + switch voteMode { + case .pre: + state.routes.push(.chatRoom(.init(battleId: battleId))) + case .post: + state.routes.push(.comment(.init(battleId: battleId))) + } + return .none + + case let .routeAction(_, action: .preVote(.delegate(.alreadyFinalVoted(battleId)))): + state.routes.push(.comment(.init(battleId: battleId))) return .none case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): return .send(.view(.backAction)) + case let .routeAction(_, action: .chatRoom(.delegate(.requestFinalVote(battleId)))): + state.routes.push(.preVote(.init(battleId: battleId, voteMode: .post))) + return .none + + case .routeAction(_, action: .comment(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case let .routeAction(_, action: .comment(.delegate(.openReply(comment)))): + state.routes.push( + .commentReply( + .init( + perspectiveId: comment.perspectiveId ?? 0, + parentComment: comment + ) + ) + ) + return .none + + case .routeAction(_, action: .commentReply(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case let .routeAction(_, action: .comment(.delegate(.openCuration(battleId)))): + state.routes.push(.curation(.init(battleId: battleId))) + return .none + + case .routeAction(_, action: .curation(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case .routeAction(_, action: .curation(.delegate(.close))): + // X : 채팅 플로우 전체를 빠져나가 앱 루트(홈)로 이동 + return .send(.delegate(.dismiss)) + + case let .routeAction(_, action: .curation(.delegate(.openBattle(battleId)))): + state.routes.push(.preVote(.init(battleId: battleId))) + return .none + default: return .none } @@ -103,7 +151,7 @@ extension ChatCoordinator { ) -> Effect { switch action { case .dismiss: - return .none + .none } } } @@ -114,6 +162,9 @@ extension ChatCoordinator { public enum ChatScreen { case preVote(PreVoteFeature) case chatRoom(ChatRoomFeature) + case comment(CommentFeature) + case commentReply(CommentReplyFeature) + case curation(CurationFeature) } } diff --git a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift index e724ee28..5b302ac0 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/View/ChatCoordinatorView.swift @@ -26,6 +26,15 @@ public struct ChatCoordinatorView: View { case let .chatRoom(chatRoomStore): ChatRoomView(store: chatRoomStore) .toolbar(.hidden, for: .tabBar) + case let .comment(commentStore): + CommentView(store: commentStore) + .toolbar(.hidden, for: .tabBar) + case let .commentReply(commentReplyStore): + CommentReplyView(store: commentReplyStore) + .toolbar(.hidden, for: .tabBar) + case let .curation(curationStore): + CurationView(store: curationStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift b/Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift new file mode 100644 index 00000000..0811987e --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Curation/Reducer/CurationFeature.swift @@ -0,0 +1,142 @@ +// +// CurationFeature.swift +// Chat +// +// .pen `큐레이팅 화면` — 특정 배틀 기준 흥미로운 배틀 추천 목록. +// GET /api/v1/battles/{battleId}/recommendations/interesting +// + +import Foundation + +import ComposableArchitecture +import DomainInterface +import Entity +import LogMacro +import UseCase + +@Reducer +public struct CurationFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var battleId: Int + public var isLoading: Bool = false + public var battles: [RecommendedBattle] = [] + + public init(battleId: Int = 0) { + self.battleId = battleId + } + } + + public enum Action: ViewAction { + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backButtonTapped + case closeButtonTapped + case battleTapped(battleId: Int) + } + + public enum AsyncAction: Equatable { + case fetchRecommendations + } + + public enum InnerAction: Equatable { + case recommendationsResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + case close + case openBattle(battleId: Int) + } + + nonisolated enum CancelID: Hashable { + case fetchRecommendations + } + + @Dependency(\.battleUseCase) private var battleUseCase + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .delegate: + return .none + } + } + } +} + +extension CurationFeature { + private func handleViewAction( + state _: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetchRecommendations)) + + case .backButtonTapped: + return .send(.delegate(.dismiss)) + + case .closeButtonTapped: + return .send(.delegate(.close)) + + case let .battleTapped(battleId): + return .send(.delegate(.openBattle(battleId: battleId))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchRecommendations: + state.isLoading = true + let battleId = state.battleId + return .run { [useCase = battleUseCase] send in + let result = await Result { + try await useCase.fetchRecommendedBattles(battleId: battleId) + } + .mapError(BattleError.from) + return await send(.inner(.recommendationsResponse(result))) + } + .cancellable(id: CancelID.fetchRecommendations, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .recommendationsResponse(result): + state.isLoading = false + switch result { + case let .success(page): + state.battles = page.items + case let .failure(error): + Log.error("[CurationFeature] recommendations failed: \(error.localizedDescription)") + state.battles = [] + } + return .none + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift b/Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift new file mode 100644 index 00000000..e1b4b781 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift @@ -0,0 +1,68 @@ +// +// CurationSkeletonView.swift +// Chat +// +// 큐레이팅(추천 배틀) 리스트 로딩 placeholder. +// + +import SwiftUI + +import DesignSystem + +struct CurationSkeletonView: View { + var count: Int = 4 + + var body: some View { + VStack(spacing: 16) { + ForEach(0 ..< count, id: \.self) { _ in + card() + } + } + } + + @ViewBuilder + private func card() -> some View { + VStack(alignment: .leading, spacing: 16) { + // meta + HStack(spacing: 10) { + SkeletonView(cornerRadius: 2).frame(width: 44, height: 18) + Spacer() + SkeletonView(cornerRadius: 4).frame(width: 36, height: 12) + SkeletonView(cornerRadius: 4).frame(width: 36, height: 12) + } + VStack(alignment: .leading, spacing: 4) { + SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 14) + SkeletonView(cornerRadius: 4).frame(width: 220, height: 12) + } + // versus + HStack(spacing: 8) { + optionPlaceholder() + SkeletonView(cornerRadius: 12).frame(width: 24, height: 24) + optionPlaceholder() + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + } + } + + @ViewBuilder + private func optionPlaceholder() -> some View { + HStack(spacing: 4) { + SkeletonView(cornerRadius: 20).frame(width: 40, height: 40) + VStack(spacing: 2) { + SkeletonView(cornerRadius: 4).frame(width: 44, height: 11) + SkeletonView(cornerRadius: 4).frame(width: 30, height: 10) + } + } + .frame(maxWidth: .infinity) + .padding(8) + .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + } + } +} diff --git a/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift b/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift new file mode 100644 index 00000000..2bec5b91 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift @@ -0,0 +1,238 @@ +// +// CurationView.swift +// Chat +// +// .pen `큐레이팅 화면` — 흥미 기반 배틀 추천 목록. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: CurationFeature.self) +public struct CurationView: View { + public let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + header() + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + if store.isLoading, store.battles.isEmpty { + CurationSkeletonView() + } else if store.battles.isEmpty { + emptyState() + } else { + ForEach(store.battles) { battle in + battleCard(battle) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 16) + } + } + .background(Color.beige200.ignoresSafeArea()) + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +// MARK: - Header + +private extension CurationView { + @ViewBuilder + func header() -> some View { + HStack(spacing: 0) { + Button { send(.backButtonTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .regular)) + .frame(width: 24, height: 24) + .foregroundStyle(.neutral900) + } + .buttonStyle(.plain) + + Spacer() + + Text("더 흥미로운 배틀도 있어요!") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.neutral500) + + Spacer() + + Button { send(.closeButtonTapped) } label: { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .regular)) + .frame(width: 24, height: 24) + .foregroundStyle(.neutral900) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.beige200) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + +// MARK: - Empty + +private extension CurationView { + @ViewBuilder + func emptyState() -> some View { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(.neutral300) + Text("추천할 배틀이 없어요") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral300) + } + .frame(maxWidth: .infinity) + .padding(.top, 80) + } +} + +// MARK: - Battle Card + +private extension CurationView { + @ViewBuilder + func battleCard(_ battle: RecommendedBattle) -> some View { + Button { send(.battleTapped(battleId: battle.battleId)) } label: { + VStack(alignment: .leading, spacing: 16) { + cardMeta(battle) + cardVersus(battle) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + func cardMeta(_ battle: RecommendedBattle) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + if let tag = battle.tags.first { + Text("#\(tag.name)") + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + + Spacer() + + HStack(spacing: 2) { + Image(systemName: "clock") + .font(.system(size: 11, weight: .regular)) + Text(durationText(battle.audioDuration)) + .pretendardFont(family: .Medium, size: 12) + } + .foregroundStyle(.neutral300) + + HStack(spacing: 2) { + Image(systemName: "eye") + .font(.system(size: 11, weight: .regular)) + Text("\(battle.viewCount)") + .pretendardFont(family: .Medium, size: 12) + } + .foregroundStyle(.neutral300) + } + + VStack(alignment: .leading, spacing: 4) { + Text(battle.title) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral500) + .lineSpacing(14 * 0.3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if !battle.summary.isEmpty { + Text(battle.summary) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral200) + .lineSpacing(12 * 0.4) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + @ViewBuilder + func cardVersus(_ battle: RecommendedBattle) -> some View { + HStack(spacing: 8) { + optionButton(battle.options[safe: 0]) + versusBadge() + optionButton(battle.options[safe: 1]) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + func optionButton(_ option: RecommendedBattleOption?) -> some View { + HStack(spacing: 4) { + CommentAvatarView( + imageURL: option?.imageUrl, + fallback: option?.representative ?? "", + size: 40 + ) + + VStack(spacing: 2) { + Text(option?.title ?? "") + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.neutral500) + Text(option?.representative ?? "") + .pretendardFont(family: .Regular, size: 10) + .foregroundStyle(.neutral300) + } + } + .frame(maxWidth: .infinity) + .padding(8) + .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + } + } + + @ViewBuilder + func versusBadge() -> some View { + Text("VS") + .pretendardFont(family: .Bold, size: 8) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + .background(.secondary200, in: Circle()) + } + + func durationText(_ seconds: Int) -> String { + let minutes = max(1, Int((Double(seconds) / 60).rounded())) + return "\(minutes)분" + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift b/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift new file mode 100644 index 00000000..d63224b4 --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift @@ -0,0 +1,48 @@ +// +// ShareContent.swift +// Chat +// +// 공유 시트에 실어 보낼 컨텐츠 묶음. 카드 스냅샷(우선) + 썸네일(fallback) + 본문 텍스트 + URL. +// + +import Foundation + +public struct ShareContent: Equatable { + public let title: String + public let summary: String + public let hashtags: [String] + public let optionLine: String? + public let url: String + public let thumbnailURL: String? + public let snapshotData: Data? + + public init( + title: String, + summary: String, + hashtags: [String], + optionLine: String?, + url: String, + thumbnailURL: String?, + snapshotData: Data? + ) { + self.title = title + self.summary = summary + self.hashtags = hashtags + self.optionLine = optionLine + self.url = url + self.thumbnailURL = thumbnailURL + self.snapshotData = snapshotData + } + + /// SNS / 메시지 공유 시 동행되는 본문 텍스트. + /// 빈 섹션은 자동으로 건너뛴다. + public var displayText: String { + var lines: [String] = [] + if !title.isEmpty { lines.append(title) } + if !summary.isEmpty { lines.append(summary) } + if let optionLine, !optionLine.isEmpty { lines.append(optionLine) } + let joinedHashtags = hashtags.joined(separator: " ") + if !joinedHashtags.isEmpty { lines.append(joinedHashtags) } + return lines.joined(separator: "\n\n") + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift b/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift new file mode 100644 index 00000000..c2b951fb --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift @@ -0,0 +1,25 @@ +// +// ShareItem.swift +// Chat +// +// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩한다. +// + +import Foundation + +public struct ShareItem: Equatable, Identifiable { + public let id: UUID + public let items: [Any] + + public init( + id: UUID = UUID(), + items: [Any] + ) { + self.id = id + self.items = items + } + + public static func == (lhs: ShareItem, rhs: ShareItem) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 3f3e06ce..337a731f 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -6,16 +6,24 @@ // import Foundation +import UIKit import ComposableArchitecture +import DesignSystem import DomainInterface import Entity import LogMacro +import UseCase @Reducer public struct PreVoteFeature { public init() {} + public enum VoteMode: Equatable { + case pre + case post + } + @ObservableState public struct State: Equatable { public var battle: PreVoteBattle? @@ -24,26 +32,28 @@ public struct PreVoteFeature { public var isLoading: Bool = false public var isSubmitting: Bool = false public var shareItem: ShareItem? - public var battleId: Int + public var battleId: Int = 0 + public var voteMode: VoteMode = .pre + public var myPerspective: BattlePerspective? + @Presents public var customAlert: CustomAlertState? public var isPrimaryButtonEnabled: Bool { selectedOptionId != nil && !isSubmitting } - public init(battleId: Int = 0, battle: PreVoteBattle? = nil) { - self.battleId = battleId - self.battle = battle + public var primaryButtonTitle: String { + switch voteMode { + case .pre: "사전 투표하기" + case .post: "최종 투표하기" + } } - } - /// 공유 시트 트리거. `.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 init( + battleId: Int = 0, + voteMode: VoteMode = .pre + ) { + self.battleId = battleId + self.voteMode = voteMode } } @@ -52,6 +62,7 @@ public struct PreVoteFeature { case view(View) case async(AsyncAction) case inner(InnerAction) + case scope(ScopeAction) case delegate(DelegateAction) } @@ -59,53 +70,82 @@ public struct PreVoteFeature { public enum View { case onAppear case backButtonTapped - case shareTapped + case shareTapped(snapshot: Data?) case optionTapped(optionId: Int) case primaryButtonTapped } public enum AsyncAction: Equatable { case fetchBattleDetail + case fetchMyPerspective + case deleteMyPerspective(perspectiveId: Int) + case prepareShare(ShareContent) case submitPreVote(battleId: Int, optionId: Int) + case submitPostVote(battleId: Int, optionId: Int) } public enum InnerAction: Equatable { - case battleDetailResponse(Result) - case preVoteResponse(Result) + case battleDetailResponse(Result) + case myPerspectiveResponse(Result) + case deleteMyPerspectiveResponse(Result) + case preVoteResponse(Result) + case sharePrepared(ShareItem) + case postVoteResponse(Result) + } + + public struct EmptyResult: Equatable { + public init() {} + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) } public enum DelegateAction: Equatable { case dismiss - case voteSubmitted(battleId: Int, result: PreVoteResult) + case voteSubmitted(battleId: Int, voteMode: VoteMode, result: PreVoteResult) + /// 이미 최종 투표(POST_VOTE)까지 마친 상태 — 댓글 화면으로 바로 이동. + case alreadyFinalVoted(battleId: Int) } nonisolated enum CancelID: Hashable { case fetchBattleDetail + case fetchMyPerspective + case deleteMyPerspective case submitPreVote + case submitPostVote } - @Dependency(\.battleRepository) private var battleRepository + @Dependency(\.battleUseCase) private var battleUseCase + @Dependency(\.perspectiveUseCase) private var perspectiveUseCase public var body: some Reducer { BindingReducer() Reduce { state, action in switch action { case .binding: - .none + return .none case let .view(viewAction): - handleViewAction(state: &state, action: viewAction) + return handleViewAction(state: &state, action: viewAction) case let .async(asyncAction): - handleAsyncAction(state: &state, action: asyncAction) + return handleAsyncAction(state: &state, action: asyncAction) case let .inner(innerAction): - handleInnerAction(state: &state, action: innerAction) + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) case let .delegate(delegateAction): - handleDelegateAction(state: &state, action: delegateAction) + return handleDelegateAction(state: &state, action: delegateAction) } } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } } } @@ -116,21 +156,53 @@ extension PreVoteFeature { ) -> Effect { switch action { case .onAppear: - guard state.battleDetail == nil, - state.battle == nil, - !state.isLoading - else { return .none } - return .send(.async(.fetchBattleDetail)) + var effects: [Effect] = [] + if state.battleDetail == nil, state.battle == nil, !state.isLoading { + effects.append(.send(.async(.fetchBattleDetail))) + } + // pre/post 모두 진입 시 내 참여(perspective) 여부를 조회한다. + // 이미 참여했으면 "다시 투표" 알럿 → 삭제 후 재투표. + if state.myPerspective == nil { + effects.append(.send(.async(.fetchMyPerspective))) + } + return effects.isEmpty ? .none : .merge(effects) 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 .shareTapped(snapshot): + let detail = state.battleDetail + let battle = state.battle + let title = detail?.battleInfo.title ?? battle?.titleLine1 ?? "" + let url = detail?.shareUrl ?? "https://picke.store/battles/\(state.battleId)" + let thumbnailURL = detail?.battleInfo.thumbnailUrl ?? battle?.backgroundImageURL + let summary = { + if let description = detail?.description, !description.isEmpty { return description } + if let infoSummary = detail?.battleInfo.summary, !infoSummary.isEmpty { return infoSummary } + return battle?.summary ?? "" + }() + let hashtags: [String] = { + if let tags = detail?.categoryTags, !tags.isEmpty { + return tags.map { "#\($0.name)" } + } + return battle?.tags ?? [] + }() + let optionLine: String? = { + guard let left = battle?.leftOption.stance, + let right = battle?.rightOption.stance + else { return nil } + return "🆚 A: \(left) vs B: \(right)" + }() + let content = ShareContent( + title: title, + summary: summary, + hashtags: hashtags, + optionLine: optionLine, + url: url, + thumbnailURL: thumbnailURL, + snapshotData: snapshot + ) + return .send(.async(.prepareShare(content))) case let .optionTapped(optionId): state.selectedOptionId = (state.selectedOptionId == optionId) ? nil : optionId @@ -138,8 +210,14 @@ extension PreVoteFeature { case .primaryButtonTapped: guard let optionId = state.selectedOptionId else { return .none } - state.isSubmitting = true - return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) + switch state.voteMode { + case .pre: + state.isSubmitting = true + return .send(.async(.submitPreVote(battleId: state.battleId, optionId: optionId))) + case .post: + state.isSubmitting = true + return .send(.async(.submitPostVote(battleId: state.battleId, optionId: optionId))) + } } } @@ -151,24 +229,79 @@ extension PreVoteFeature { case .fetchBattleDetail: state.isLoading = true let battleId = state.battleId - return .run { [repository = battleRepository] send in + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.fetchBattle(battleId: battleId) } - .mapError(AuthError.from) + .mapError(BattleError.from) return await send(.inner(.battleDetailResponse(result))) } .cancellable(id: CancelID.fetchBattleDetail, cancelInFlight: true) + case .fetchMyPerspective: + let battleId = state.battleId + return .run { [repository = battleUseCase] send in + let result = await Result { + try await repository.fetchMyPerspective(battleId: battleId) + } + .mapError(BattleError.from) + return await send(.inner(.myPerspectiveResponse(result))) + } + .cancellable(id: CancelID.fetchMyPerspective, cancelInFlight: true) + + case let .deleteMyPerspective(perspectiveId): + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.deletePerspective(perspectiveId: perspectiveId) + return EmptyResult() + } + .mapError(PerspectiveError.from) + return await send(.inner(.deleteMyPerspectiveResponse(result))) + } + .cancellable(id: CancelID.deleteMyPerspective, cancelInFlight: true) + + case let .prepareShare(content): + return .run { send in + var items: [Any] = [content.displayText] + + if let url = URL(string: content.url) { + items.append(url) + } else { + items.append(content.url) + } + + if let data = content.snapshotData, let image = UIImage(data: data) { + items.append(image) + } else if let thumbnailURL = content.thumbnailURL, + let remoteURL = URL(string: thumbnailURL), + let (data, _) = try? await URLSession.shared.data(from: remoteURL), + let image = UIImage(data: data) + { + items.append(image) + } + + await send(.inner(.sharePrepared(ShareItem(items: items)))) + } + case let .submitPreVote(battleId, optionId): - return .run { [repository = battleRepository] send in + return .run { [repository = battleUseCase] send in let result = await Result { try await repository.submitPreVote(battleId: battleId, optionId: optionId) } - .mapError(AuthError.from) + .mapError(BattleError.from) return await send(.inner(.preVoteResponse(result))) } .cancellable(id: CancelID.submitPreVote, cancelInFlight: true) + + case let .submitPostVote(battleId, optionId): + return .run { [repository = battleUseCase] send in + let result = await Result { + try await repository.submitPostVote(battleId: battleId, optionId: optionId) + } + .mapError(BattleError.from) + return await send(.inner(.postVoteResponse(result))) + } + .cancellable(id: CancelID.submitPostVote, cancelInFlight: true) } } @@ -188,14 +321,51 @@ extension PreVoteFeature { } return .none + case let .myPerspectiveResponse(result): + switch result { + case let .success(perspective): + state.myPerspective = perspective + if perspective != nil { + state.customAlert = .alreadyWatched() + } + case let .failure(error): + Log.error("[PreVoteFeature] fetchMyPerspective failed: \(error.localizedDescription)") + } + return .none + + case let .deleteMyPerspectiveResponse(result): + switch result { + case .success: + state.myPerspective = nil + case let .failure(error): + Log.error("[PreVoteFeature] deleteMyPerspective 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))) + return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .pre, 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)))) + return .none + } + + case let .sharePrepared(item): + state.shareItem = item + return .none + + case let .postVoteResponse(result): + state.isSubmitting = false + switch result { + case let .success(voteResult): + return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .post, result: voteResult))) + case let .failure(error): + // 최종투표는 1회만 가능 — 이미 투표한 경우 서버가 500. + // 재투표가 불가하므로 결과(댓글) 화면으로 이동한다. + Log.error("[PreVoteFeature] submitPostVote failed: \(error.localizedDescription)") + return .send(.delegate(.alreadyFinalVoted(battleId: state.battleId))) } } } @@ -236,8 +406,35 @@ extension PreVoteFeature { action: DelegateAction ) -> Effect { switch action { - case .dismiss, .voteSubmitted: - .none + case .dismiss, .voteSubmitted, .alreadyFinalVoted: + 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: + let perspectiveId = state.myPerspective?.perspectiveId + state.customAlert = nil + guard let perspectiveId else { return .none } + return .send(.async(.deleteMyPerspective(perspectiveId: perspectiveId))) + + case .cancelTapped: + state.customAlert = nil + return .send(.delegate(.dismiss)) + } + + case .dismiss: + state.customAlert = nil + return .none + } } } } diff --git a/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift b/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift index e66d8110..7d3dd61a 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/Components/PreVoteSkeletonView.swift @@ -13,10 +13,10 @@ import DesignSystem struct PreVoteSkeletonView: View { var body: some View { VStack(spacing: 0) { - hero - contentSection + hero() + contentSection() Spacer(minLength: 0) - ctaButton + ctaButton() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color.beige50.ignoresSafeArea()) @@ -27,7 +27,7 @@ struct PreVoteSkeletonView: View { private extension PreVoteSkeletonView { @ViewBuilder - var hero: some View { + func hero() -> some View { SkeletonView(cornerRadius: 6) .frame(height: 329) .overlay(alignment: .top) { @@ -42,19 +42,19 @@ private extension PreVoteSkeletonView { private extension PreVoteSkeletonView { @ViewBuilder - var contentSection: some View { + func contentSection() -> some View { VStack(alignment: .leading, spacing: 16) { - tagsRow - titleBlock - summaryBlock - optionsRow + tagsRow() + titleBlock() + summaryBlock() + optionsRow() } .padding(.horizontal, 16) .padding(.top, 24) } @ViewBuilder - var tagsRow: some View { + func tagsRow() -> some View { HStack(spacing: 8) { SkeletonView(cornerRadius: 6) .frame(width: 29, height: 17) @@ -64,19 +64,19 @@ private extension PreVoteSkeletonView { } @ViewBuilder - var titleBlock: some View { + func titleBlock() -> some View { SkeletonView(cornerRadius: 6) .frame(width: 167, height: 68) } @ViewBuilder - var summaryBlock: some View { + func summaryBlock() -> some View { SkeletonView(cornerRadius: 6) .frame(width: 235.5, height: 61.43) } @ViewBuilder - var optionsRow: some View { + func optionsRow() -> some View { ZStack { HStack(spacing: 8) { SkeletonView(cornerRadius: 6) @@ -94,7 +94,7 @@ private extension PreVoteSkeletonView { private extension PreVoteSkeletonView { @ViewBuilder - var ctaButton: some View { + func ctaButton() -> some View { SkeletonView(cornerRadius: 6) .frame(width: 87, height: 24) .padding(.bottom, 40) diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift new file mode 100644 index 00000000..bf57726c --- /dev/null +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift @@ -0,0 +1,25 @@ +// +// PreVoteLayout.swift +// Chat +// +// PreVote 화면 레이아웃 토큰. +// + +import CoreGraphics + +enum PreVoteLayout { + static let contentOverlapTopOffset: CGFloat = 280 + static let contentGradientSpacerHeight: CGFloat = 100 + static let backgroundImageHeight: CGFloat = 512 + static let imageToContentGradientHeight: CGFloat = 260 + static let rootContentSpacing: CGFloat = 40 + static let contentToOptionSpacing: CGFloat = 32 + static let optionCardHeight: CGFloat = 106 + static let contentHorizontalPadding: CGFloat = 16 + static let contentTopPadding: CGFloat = 80 + static let ctaHeight: CGFloat = 52 + static let ctaBottomSpacing: CGFloat = 40 + static let ctaHorizontalPadding: CGFloat = 16 + static let contentBottomSpacing: CGFloat = ctaHeight + ctaBottomSpacing + rootContentSpacing + static let snapshotWidth: CGFloat = 360 +} diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index 683621f8..8d29d126 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -25,7 +25,7 @@ public struct PreVoteView: View { if shouldShowSkeleton { PreVoteSkeletonView() } else { - loadedContent + loadedContent() } } .background(Color.beige50.ignoresSafeArea()) @@ -34,14 +34,14 @@ public struct PreVoteView: View { .toolbar(.hidden, for: .tabBar) .overlay(alignment: .bottom) { if !shouldShowSkeleton { - primaryButton - .padding(.horizontal, Self.ctaHorizontalPadding) - .padding(.bottom, Self.ctaBottomSpacing) + primaryButton() + .padding(.horizontal, PreVoteLayout.ctaHorizontalPadding) + .padding(.bottom, PreVoteLayout.ctaBottomSpacing) } } .overlay(alignment: .top) { if !shouldShowSkeleton { - navigationBar + navigationBar() .background(Color.clear) .padding(.top, 12) .frame(maxWidth: .infinity) @@ -55,6 +55,7 @@ public struct PreVoteView: View { .presentationDetents([.fraction(0.6)]) .toolbar(.hidden, for: .navigationBar) } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) } private var shouldShowSkeleton: Bool { @@ -62,7 +63,7 @@ public struct PreVoteView: View { } @ViewBuilder - private var loadedContent: some View { + private func loadedContent() -> some View { if let battle = store.battle { GeometryReader { proxy in ZStack(alignment: .top) { @@ -72,13 +73,17 @@ public struct PreVoteView: View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { Color.clear - .frame(height: Self.contentOverlapTopOffset) + .frame(height: PreVoteLayout.contentOverlapTopOffset) + + Spacer() + .frame(height: PreVoteLayout.contentGradientSpacerHeight) contentArea(battle) } .frame(width: proxy.size.width) } .scrollBounceBehavior(.basedOnSize) + .scrollDisabled(true) } .ignoresSafeArea(edges: .top) } @@ -87,14 +92,6 @@ public struct PreVoteView: View { } } - 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 @@ -115,18 +112,37 @@ extension PreVoteView { } Color.black.opacity(0.4) + + VStack { + Spacer() + imageToContentGradient() + } } .frame(maxWidth: .infinity) - .frame(height: 512) + .frame(height: PreVoteLayout.backgroundImageHeight) .clipped() } + + @ViewBuilder + private func imageToContentGradient() -> some View { + LinearGradient( + stops: [ + .init(color: .beige50.opacity(0), location: 0), + .init(color: .beige50.opacity(0.55), location: 0.55), + .init(color: .beige50, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: PreVoteLayout.imageToContentGradientHeight) + } } // MARK: - Navigation bar extension PreVoteView { @ViewBuilder - private var navigationBar: some View { + private func navigationBar() -> some View { HStack { Button { send(.backButtonTapped) } label: { Image(systemName: "chevron.left") @@ -138,7 +154,9 @@ extension PreVoteView { Spacer() - Button { send(.shareTapped) } label: { + Button { + send(.shareTapped(snapshot: captureCardSnapshot())) + } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 24, weight: .regular)) .frame(width: 24, height: 24) @@ -156,19 +174,20 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder private func contentArea(_ battle: PreVoteBattle) -> some View { - VStack(spacing: Self.contentToOptionSpacing) { + VStack(spacing: PreVoteLayout.contentToOptionSpacing) { contentSection(battle) optionSection(battle) } - .padding(.horizontal, 16) - .padding(.top, 80) - .padding(.bottom, Self.contentBottomSpacing) + .padding(.horizontal, PreVoteLayout.contentHorizontalPadding) + .padding(.top, PreVoteLayout.contentTopPadding) + .padding(.bottom, PreVoteLayout.contentBottomSpacing) .frame(maxWidth: .infinity) .background( LinearGradient( stops: [ - .init(color: Color.beige50.opacity(0), location: 0), - .init(color: .beige50, location: 0.35), + .init(color: .beige50.opacity(0), location: 0), + .init(color: .beige50.opacity(0.72), location: 0.42), + .init(color: .beige50, location: 0.7), .init(color: .beige50, location: 1), ], startPoint: .top, @@ -239,7 +258,7 @@ extension PreVoteView { optionCard(battle.rightOption) } .frame(maxWidth: .infinity) - vsBadge + vsBadge() } } @@ -272,7 +291,7 @@ extension PreVoteView { } .padding(8) .frame(maxWidth: .infinity) - .frame(height: Self.optionCardHeight) + .frame(height: PreVoteLayout.optionCardHeight) .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) @@ -297,7 +316,7 @@ extension PreVoteView { } @ViewBuilder - private var vsBadge: some View { + private func vsBadge() -> some View { Text("VS") .pretendardFont(family: .Bold, size: 11) .foregroundStyle(.neutral800) @@ -311,19 +330,42 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private var primaryButton: some View { + private func primaryButton() -> some View { CustomButton( action: { send(.primaryButtonTapped) }, - title: "사전 투표하기", - config: CustomButtonConfig.primary(.large, height: Self.ctaHeight), + title: store.primaryButtonTitle, + config: CustomButtonConfig.primary(.large, height: PreVoteLayout.ctaHeight), isEnable: store.isPrimaryButtonEnabled ) } } +// MARK: - Share snapshot + +extension PreVoteView { + @MainActor + private func captureCardSnapshot() -> Data? { + guard let battle = store.battle else { return nil } + let renderer = ImageRenderer(content: shareSnapshotCard(battle)) + renderer.scale = UIScreen.main.scale + return renderer.uiImage?.pngData() + } + + @ViewBuilder + private func shareSnapshotCard(_ battle: PreVoteBattle) -> some View { + VStack(spacing: PreVoteLayout.contentToOptionSpacing) { + contentSection(battle) + optionSection(battle) + } + .padding(16) + .frame(width: PreVoteLayout.snapshotWidth) + .background(Color.beige50) + } +} + #Preview { PreVoteView( - store: Store(initialState: PreVoteFeature.State(battle: .mock)) { + store: Store(initialState: PreVoteFeature.State()) { PreVoteFeature() } ) diff --git a/Projects/Presentation/Hifi/Project.swift b/Projects/Presentation/Hifi/Project.swift new file mode 100644 index 00000000..b07893cc --- /dev/null +++ b/Projects/Presentation/Hifi/Project.swift @@ -0,0 +1,18 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeAppModule( + name: "Hifi", + bundleId: .appBundleID(name: ".Hifi"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Presentation(implements: .Chat), + .SPM.composableArchitecture, + .SPM.tcaFlow, + ], + sources: ["Sources/**"] +) \ No newline at end of file diff --git a/Projects/Presentation/Hifi/Sources/Base.swift b/Projects/Presentation/Hifi/Sources/Base.swift new file mode 100644 index 00000000..b1d530b1 --- /dev/null +++ b/Projects/Presentation/Hifi/Sources/Base.swift @@ -0,0 +1,22 @@ +// +// base.swift +// DDDAttendance. +// +// Created by Roy on 2026-06-04 +// Copyright © 2026 DDD , Ltd., All rights reserved. +// + +import SwiftUI + +struct BaseView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundColor(.accentColor) + Text("Hello, world!") + } + .padding() + } +} + diff --git a/Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift b/Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift new file mode 100644 index 00000000..21c54d00 --- /dev/null +++ b/Projects/Presentation/Hifi/Tests/Sources/HifiTests.swift @@ -0,0 +1,27 @@ +// +// HifiTests.swift +// Presentation.HifiTests +// +// Created by Roy on 2026-06-04. +// + +import Testing +@testable import Hifi + +struct HifiTests { + + @Test + func hifiExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func hifiLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} + diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 7feaaeb3..5a1148e1 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -43,7 +43,10 @@ public struct HomeCoordinator { public enum InnerAction: Equatable {} public enum NavigationAction: Equatable {} - func handleRoute(state: inout State, action: Action) -> Effect { + func handleRoute( + state: inout State, + action: Action + ) -> Effect { switch action { case let .router(routeAction): routerAction(state: &state, action: routeAction) diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 5ea33f4e..00cbe01e 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -7,6 +7,7 @@ import ComposableArchitecture import DomainInterface +import UseCase import Entity import Foundation import LogMacro @@ -77,7 +78,7 @@ public struct HomeFeature { case fetchHome } - @Dependency(\.homeRepository) private var homeRepository + @Dependency(\.homeUseCase) private var homeUseCase public var body: some Reducer { BindingReducer() @@ -139,7 +140,7 @@ extension HomeFeature { switch action { case .fetchHome: state.isLoading = true - return .run { [repository = homeRepository] send in + return .run { [repository = homeUseCase] send in let result = await Result { try await repository.fetchHome() } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift index f1e17dd7..667beb1e 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift @@ -4,7 +4,7 @@ // // Created by Wonji Suh on 5/15/26. // -// Pencil .pen `Card/Quiz` — 단일 상태 (Property variant 없음). +// Pencil .pen `Card/Quiz` — 선택 전/후 (O 정답 · X 오답) 상태 미러. // import SwiftUI @@ -12,10 +12,17 @@ import SwiftUI import DesignSystem import Entity -/// "오늘의 Pické — 퀴즈" 카드. (선택/결과 상태 분리 없음 — .pen 디자인 단일) +/// "오늘의 Pické — 퀴즈" 카드. 옵션 탭 → 정답 비교 → O/X 결과 라벨 노출. struct QuizCardView: View { let question: QuizQuestion + @State private var selectedOption: Choice? + + enum Choice: Equatable { + case a + case b + } + var body: some View { VStack(alignment: .leading, spacing: 20) { header() @@ -59,26 +66,54 @@ struct QuizCardView: View { @ViewBuilder private func options() -> some View { HStack(spacing: 8) { - option(label: question.itemA, desc: question.itemADesc) - option(label: question.itemB, desc: question.itemBDesc) + optionButton(.a, label: question.itemA, desc: question.itemADesc, isCorrect: question.isCorrectA) + optionButton(.b, label: question.itemB, desc: question.itemBDesc, isCorrect: question.isCorrectB) } } @ViewBuilder - private func option(label: String, desc: String) -> some View { - VStack(spacing: 2) { - Text(label) - .pretendardFont(family: .SemiBold, size: 13) - .foregroundStyle(.neutral900) - Text(desc) - .pretendardFont(family: .Medium, size: 11) - .foregroundStyle(.neutral300) + private func optionButton( + _ choice: Choice, + label: String, + desc: String, + isCorrect: Bool + ) -> some View { + let isSelected = selectedOption == choice + let hasAnswered = selectedOption != nil + Button { + selectedOption = isSelected ? nil : choice + } label: { + VStack(spacing: 2) { + resultBadge(isSelected: isSelected, isCorrect: isCorrect) + Text(label) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.neutral900) + Text(desc) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.neutral300) + } + .frame(maxWidth: .infinity) + .padding(12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige500, lineWidth: 1) + ) + .opacity(hasAnswered && !isSelected ? 0.5 : 1) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func resultBadge( + isSelected: Bool, + isCorrect: Bool + ) -> some View { + if isSelected { + Text(isCorrect ? "O 정답" : "X 오답") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(isCorrect ? .secondary500 : .primary500) + } else { + Color.clear.frame(height: 14) } - .frame(maxWidth: .infinity) - .padding(12) - .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) - .overlay( - RoundedRectangle(cornerRadius: 2).stroke(.beige500, lineWidth: 1) - ) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift index 6d3b5812..ebc7270e 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift @@ -130,7 +130,10 @@ struct VoteCardView: View { } @ViewBuilder - private func optionButton(index: Int, label: String) -> some View { + private func optionButton( + index: Int, + label: String + ) -> some View { let isSelected = selectedIndex == index Button { @@ -176,7 +179,10 @@ struct VoteCardView: View { } @ViewBuilder - private func resultBarRow(label: String, percentage: Int) -> some View { + private func resultBarRow( + label: String, + percentage: Int + ) -> some View { HStack(spacing: 6) { Text(label) .pretendardFont(family: .Medium, size: 10) diff --git a/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift b/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift index 496e1328..b399fd6b 100644 --- a/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift +++ b/Projects/Presentation/Splash/Sources/View/Components/SplashLogoAnimatedImageView.swift @@ -23,7 +23,10 @@ struct SplashLogoAnimatedImageView: UIViewRepresentable { return imageView } - func updateUIView(_ imageView: SDAnimatedImageView, context: Context) { + func updateUIView( + _ imageView: SDAnimatedImageView, + context: Context + ) { guard imageView.image !== Self.image else { return } imageView.image = Self.image imageView.startAnimating() diff --git a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift index 500e2835..07a7ccea 100644 --- a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift +++ b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift @@ -4,8 +4,8 @@ import SwiftUI public extension ShapeStyle where Self == Color { - // MARK: - Primitive / Primary + static var primary50: Color { .init(hex: "F3EBE9") } static var primary100: Color { .init(hex: "E7D7D3") } static var primary200: Color { .init(hex: "D0AFA8") } @@ -18,6 +18,7 @@ public extension ShapeStyle where Self == Color { static var primary900: Color { .init(hex: "4E2A21") } // MARK: - Primitive / Secondary + static var secondary50: Color { .init(hex: "FCF8F1") } static var secondary100: Color { .init(hex: "F9F1E3") } static var secondary200: Color { .init(hex: "F3E3C7") } @@ -30,6 +31,7 @@ public extension ShapeStyle where Self == Color { static var secondary900: Color { .init(hex: "92784A") } // MARK: - Primitive / Beige + static var beige50: Color { .init(hex: "FEFEFD") } static var beige100: Color { .init(hex: "FDFCFB") } static var beige200: Color { .init(hex: "FBF9F7") } @@ -42,6 +44,7 @@ public extension ShapeStyle where Self == Color { static var beige900: Color { .init(hex: "B7A88B") } // MARK: - Primitive / Gray + static var gray50: Color { .init(hex: "EBEBEB") } static var gray100: Color { .init(hex: "D7D7D7") } static var gray200: Color { .init(hex: "B0AFAE") } @@ -54,6 +57,7 @@ public extension ShapeStyle where Self == Color { static var gray900: Color { .init(hex: "131212") } // MARK: - Compat / Neutral (alias of Gray) + static var neutral50: Color { .gray50 } static var neutral100: Color { .gray100 } static var neutral200: Color { .gray200 } @@ -66,18 +70,22 @@ public extension ShapeStyle where Self == Color { static var neutral900: Color { .gray900 } // MARK: - Primitive / Status + static var errorAlpha: Color { .init(hex: "C92D33", alpha: 0.4) } static var errorDefault: Color { .init(hex: "C92D33") } + static var errorStrong: Color { .init(hex: "D7110C") } static var warningDefault: Color { .init(hex: "FFB400") } static var warningAlpha: Color { .init(hex: "FFB400", alpha: 0.4) } // MARK: - Semantic / Background + static var bgBeige: Color { .beige200 } static var bgDefault: Color { .init(hex: "FAFAF9") } static var bgSubtle: Color { .init(hex: "F5F5F4") } static var bgSubtler: Color { .gray50 } // MARK: - Semantic / Text + static var textBody: Color { .gray400 } static var textDefault: Color { .gray800 } static var textError: Color { .errorDefault } @@ -89,6 +97,7 @@ public extension ShapeStyle where Self == Color { static var textSubtler: Color { .gray500 } // MARK: - Semantic / Border + static var borderBeigeDefault: Color { .beige600 } static var borderBeigeDisabled: Color { .beige500 } static var borderBeigeFocus: Color { .beige700 } @@ -101,6 +110,7 @@ public extension ShapeStyle where Self == Color { static var borderWarningDefault: Color { .init(hex: "FFB400", alpha: 0.4) } // MARK: - Semantic / Surface + static var surfaceBeigeDefault: Color { .beige50 } static var surfaceBeigeStrong: Color { .beige400 } static var surfaceBeigeSubtle: Color { .beige300 } @@ -108,6 +118,7 @@ public extension ShapeStyle where Self == Color { static var surfacePrimarySubtle: Color { .primary50 } // MARK: - Semantic / Action + static var actionBeigeDefault: Color { .beige300 } static var actionBeigePressed: Color { .beige400 } static var actionBeigeStrong: Color { .beige600 } @@ -121,12 +132,14 @@ public extension ShapeStyle where Self == Color { static var actionSecondaryDefault: Color { .secondary50 } // MARK: - Semantic / Icon + static var iconGrayDefault: Color { .gray900 } static var iconGrayInverse: Color { .beige50 } static var iconGraySubtle: Color { .gray300 } static var iconPrimaryDefault: Color { .primary500 } // MARK: - Component + static var avatarBackround: Color { .beige600 } static var badgeCounterBackground: Color { .gray500 } static var badgeCounterTextActive: Color { .beige50 } diff --git a/Projects/Shared/DesignSystem/Sources/CustomFont/PretendardFont.swift b/Projects/Shared/DesignSystem/Sources/CustomFont/PretendardFont.swift index 88c34769..565ff081 100644 --- a/Projects/Shared/DesignSystem/Sources/CustomFont/PretendardFont.swift +++ b/Projects/Shared/DesignSystem/Sources/CustomFont/PretendardFont.swift @@ -17,7 +17,10 @@ public struct PretendardFont: ViewModifier { } public extension View { - func pretendardFont(family: PretendardFontFamily, size: CGFloat) -> some View { + func pretendardFont( + family: PretendardFontFamily, + size: CGFloat + ) -> some View { return self.modifier(PretendardFont(family: family, size: size)) } @@ -27,14 +30,20 @@ public extension View { } public extension UIFont { - static func pretendardFontFamily(family: PretendardFontFamily, size: CGFloat) -> UIFont { + static func pretendardFontFamily( + family: PretendardFontFamily, + size: CGFloat + ) -> UIFont { let fontName = "PretendardVariable-\(family)" return UIFont(name: fontName, size: size) ?? UIFont.systemFont(ofSize: size, weight: .regular) } } public extension Font { - static func pretendardFontFamily(family: PretendardFontFamily, size: CGFloat) -> Font{ + static func pretendardFontFamily( + family: PretendardFontFamily, + size: CGFloat + ) -> Font{ let font = Font.custom("PretendardVariable-\(family)", size: size) return font } diff --git a/Projects/Shared/DesignSystem/Sources/Extension/Color/Color+.swift b/Projects/Shared/DesignSystem/Sources/Extension/Color/Color+.swift index 34c1a265..dcf1c180 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/Color/Color+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/Color/Color+.swift @@ -8,7 +8,10 @@ import SwiftUI public extension Color { - init(hex: String, alpha: Double = 1.0) { + init( + hex: String, + alpha: Double = 1.0 + ) { let scanner = Scanner(string: hex) _ = scanner.scanString("#") var rgb: UInt64 = 0 diff --git a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift index dd1dc6ff..d9337f60 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift @@ -8,7 +8,10 @@ import UIKit public extension UIColor { - convenience init(hex: String, alpha: Double = 1.0) { + convenience init( + hex: String, + alpha: Double = 1.0 + ) { let scanner = Scanner(string: hex) _ = scanner.scanString("#") diff --git a/Projects/Shared/DesignSystem/Sources/UI/ActionSheet/BottomActionSheet.swift b/Projects/Shared/DesignSystem/Sources/UI/ActionSheet/BottomActionSheet.swift new file mode 100644 index 00000000..8901d7b1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/ActionSheet/BottomActionSheet.swift @@ -0,0 +1,73 @@ +// +// BottomActionSheet.swift +// DesignSystem +// +// 아이콘 + 라벨 행을 가진 하단 액션 시트. +// 댓글 "…" 메뉴(수정/삭제/신고)처럼 이미지가 필요한 메뉴에 사용한다. +// + +import SwiftUI + +public struct BottomActionItem: Identifiable { + public let id = UUID() + public let title: String + public let systemImage: String + public let isDestructive: Bool + public let action: () -> Void + + public init( + title: String, + systemImage: String, + isDestructive: Bool = false, + action: @escaping () -> Void + ) { + self.title = title + self.systemImage = systemImage + self.isDestructive = isDestructive + self.action = action + } +} + +public struct BottomActionSheet: View { + private let items: [BottomActionItem] + private let onDismiss: () -> Void + + public init( + items: [BottomActionItem], + onDismiss: @escaping () -> Void + ) { + self.items = items + self.onDismiss = onDismiss + } + + public var body: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { onDismiss() } + + VStack(spacing: 8) { + ForEach(items) { item in + Button { + onDismiss() + item.action() + } label: { + HStack(spacing: 4) { + Image(systemName: item.systemImage) + .font(.system(size: 14, weight: .medium)) + Text(item.title) + .pretendardFont(family: .Medium, size: 13) + } + .foregroundStyle(Color.beige50) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(Color.primary500, in: Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(.bottom, 28) + } + .transition(.opacity) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift index 11169e2a..1de4fa04 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift @@ -35,6 +35,9 @@ public struct CustomAlertState: Equatable { public enum CustomAlertStyle: Equatable { case confirmation case finalVote + case report + case alreadyWatched + case deleteConfirm } @CasePathable @@ -78,4 +81,45 @@ public extension CustomAlertState where Action == CustomAlertAction { style: .finalVote ) } + + static func deletePerspective() -> CustomAlertState { + CustomAlertState( + title: "관점을 삭제하시겠습니까?", + confirmTitle: "삭제하기", + cancelTitle: "뒤로가기", + isDestructive: true, + style: .deleteConfirm + ) + } + + static func deleteComment() -> CustomAlertState { + CustomAlertState( + title: "댓글을 삭제하시겠습니까?", + confirmTitle: "삭제하기", + cancelTitle: "뒤로가기", + isDestructive: true, + style: .deleteConfirm + ) + } + + static func report() -> CustomAlertState { + CustomAlertState( + title: "", + confirmTitle: "신고", + cancelTitle: "", + isDestructive: true, + style: .report + ) + } + + static func alreadyWatched() -> CustomAlertState { + CustomAlertState( + title: "다시 콘텐츠를 시청하시겠습니까?", + message: "이미 참여 완료한 배틀입니다.\n기존 내역이 삭제되고\n다시 처음부터 진행됩니다.", + confirmTitle: "다시", + cancelTitle: "취소", + isDestructive: true, + style: .alreadyWatched + ) + } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index e9f438fe..869fe439 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -45,7 +45,7 @@ struct CustomConfirmationPopup: View { .onTapGesture(perform: onCancel) popupContent - .padding(.horizontal, 20) + .padding(.horizontal, popupHorizontalPadding) .offset(y: isContentVisible ? 0 : 120) .opacity(isContentVisible ? 1 : 0) } @@ -56,6 +56,15 @@ struct CustomConfirmationPopup: View { } } + private var popupHorizontalPadding: CGFloat { + switch style { + case .confirmation: + return 20 + case .finalVote, .report, .alreadyWatched, .deleteConfirm: + return 0 + } + } + @ViewBuilder private var popupContent: some View { switch style { @@ -63,7 +72,113 @@ struct CustomConfirmationPopup: View { confirmationContent case .finalVote: finalVoteContent + case .report: + reportContent + case .alreadyWatched: + alreadyWatchedContent + case .deleteConfirm: + deleteConfirmContent + } + } + + private var deleteConfirmContent: 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) + + HStack(spacing: 0) { + // 삭제하기 (왼쪽·밝은 버튼) = 확정(삭제) + Button(action: onConfirm) { + Text(confirmTitle) + .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: onCancel) { + Text(cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .lineSpacing(14 * 0.4) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } } + .padding(.top, 20) + .frame(width: 313) + .background(.beige500, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.primary500, lineWidth: 1.5) + ) + .opacity(0.9) + .clipShape(RoundedRectangle(cornerRadius: 2)) + .onTapGesture {} + } + + @ViewBuilder + private var alreadyWatchedContent: some View { + VStack(spacing: 12) { + VStack(spacing: 8) { + Text(title) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.primary800) + .kerning(-0.4) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + + if !message.isEmpty { + Text(message) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.neutral400) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + } + + HStack(spacing: 0) { + Button(action: onCancel) { + Text(cancelTitle.isEmpty ? "취소" : cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary500) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onConfirm) { + Text(confirmTitle.isEmpty ? "다시" : confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 24) + .frame(width: 343) + .background(.beige500, in: RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(.primary500, lineWidth: 1.5) + ) + .onTapGesture {} } private var confirmationContent: some View { @@ -130,9 +245,8 @@ struct CustomConfirmationPopup: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(.horizontal, 20) - .padding(.vertical, 20) - HStack(spacing: 10) { + HStack(spacing: 0) { Button(action: onCancel) { Text(cancelTitle) .pretendardFont(family: .Medium, size: 14) @@ -156,6 +270,7 @@ struct CustomConfirmationPopup: View { .buttonStyle(.plain) } } + .padding(.top, 20) .frame(width: 313) .background(.beige500, in: RoundedRectangle(cornerRadius: 2)) .overlay( @@ -165,4 +280,148 @@ struct CustomConfirmationPopup: View { .opacity(0.9) .onTapGesture {} } + + @State private var selectedReason: ReportReason? + + @ViewBuilder + private var reportContent: some View { + VStack(spacing: 16) { + reportHeader + reasonGrid + reportButtons + } + .padding(.top, 20) + .frame(width: 343) + .background(.beige500, in: RoundedRectangle(cornerRadius: 6)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(.primary500, lineWidth: 1.5) + ) + .onTapGesture {} + } + + @ViewBuilder + private var reportHeader: some View { + Text("신고사유") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + } + + @ViewBuilder + private var reasonGrid: some View { + HStack(alignment: .top, spacing: 0) { + reasonColumn(ReportReason.leftColumn) + reasonColumn(ReportReason.rightColumn, fillSpacer: true) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .frame(height: 116) + } + + @ViewBuilder + private func reasonColumn( + _ reasons: [ReportReason], + fillSpacer: Bool = false + ) -> some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(reasons) { reason in + reasonRow(reason) + } + if fillSpacer { + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func reasonRow(_ reason: ReportReason) -> some View { + Button { + selectedReason = reason + } label: { + HStack(spacing: 6) { + reasonRadio(isSelected: selectedReason == reason) + Text(reason.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.neutral900) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func reasonRadio(isSelected: Bool) -> some View { + ZStack { + Circle() + .stroke(.gray200, lineWidth: 3) + .frame(width: 20, height: 20) + if isSelected { + Circle() + .fill(.primary500) + .frame(width: 10, height: 10) + } + } + } + + @ViewBuilder + private var reportButtons: some View { + HStack(spacing: 10) { + reportSubmitButton + reportCancelButton + } + } + + @ViewBuilder + private var reportSubmitButton: some View { + Button(action: onConfirm) { + Text(confirmTitle.isEmpty ? "신고하기" : confirmTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + .disabled(selectedReason == nil) + .opacity(selectedReason == nil ? 0.5 : 1) + } + + @ViewBuilder + private var reportCancelButton: some View { + Button(action: onCancel) { + Text(cancelTitle.isEmpty ? "뒤로가기" : cancelTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) + } +} + +public enum ReportReason: String, CaseIterable, Identifiable, Equatable { + case commercial + case repeat_ + case explicit + case insult + case other + + public var id: String { rawValue } + + public var title: String { + switch self { + case .commercial: "영리목적/홍보성" + case .repeat_: "같은 내용 반복 게시" + case .explicit: "음란성/선정성" + case .insult: "욕설/인신공격" + case .other: "기타" + } + } + + static let leftColumn: [ReportReason] = [.commercial, .explicit, .other] + static let rightColumn: [ReportReason] = [.repeat_, .insult] } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift b/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift index 2a540198..9ad66625 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Button/CTAButtonStyle.swift @@ -15,7 +15,10 @@ public enum CTAButtonVariant: Sendable { } public extension CTAButtonVariant { - func backgroundColor(isEnabled: Bool, isPressed: Bool = false) -> Color { + func backgroundColor( + isEnabled: Bool, + isPressed: Bool = false + ) -> Color { switch self { case .primary: guard isEnabled else { return ComponentToken.Button.Primary.Background.disabled } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Floating/FloatingErrorView.swift b/Projects/Shared/DesignSystem/Sources/UI/Floating/FloatingErrorView.swift new file mode 100644 index 00000000..ccabae65 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Floating/FloatingErrorView.swift @@ -0,0 +1,36 @@ +// +// FloatingErrorView.swift +// DesignSystem +// +// .pen `채팅방_오류` 의 오류 메세지 floating 배너. +// 빨간 배경 + 경고 아이콘 + 메시지. 상단에 떠 있는 형태. +// + +import SwiftUI + +public struct FloatingErrorView: View { + private let message: String + + public init(message: String) { + self.message = message + } + + public var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.beige50) + + Text(message) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.beige50) + .kerning(-0.35) + .lineSpacing(14 * 0.28) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(.errorStrong, in: RoundedRectangle(cornerRadius: 9)) + .shadow(color: Color(hex: "5B0604").opacity(0.28), radius: 5.25, x: 0, y: 0) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift b/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift index 367d1160..69d474d5 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Share/ShareSheet.swift @@ -19,5 +19,8 @@ public struct ShareSheet: UIViewControllerRepresentable { UIActivityViewController(activityItems: items, applicationActivities: nil) } - public func updateUIViewController(_: UIActivityViewController, context _: Context) {} + public func updateUIViewController( + _: UIActivityViewController, + context _: Context + ) {} } diff --git a/README.md b/README.md index 324d36db..aeeb89d8 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,17 @@ ln -s AGENTS.md CLAUDE.md - **사전 투표 → 1:1 채팅 토론 → 사후 투표** 의 한 흐름 - **재투표** 로 가치관이 바뀌었는지 추적 - **리캡 카드** 자동 생성 + 공유 +- **채팅방 오디오 재생** + 로딩 실패 시 상단 floating 오류 배너(`FloatingErrorView`) -### 💬 토론 / 댓글 -- 채팅방형 1:1 토론 -- 콘텐츠별 댓글·대댓글 -- 신고·차단 +### 💬 토론 / 관점(댓글) +- 채팅방형 1:1 음성 토론 +- 관점(=댓글) 등록·수정·삭제 + 대댓글, 좋아요, 신고 +- 투표 진영(optionId)별 관점 등록 / 진영 탭 필터 +- 본인 글 "나" 표시 + 수정·삭제 메뉴, 등록·갱신 시 스켈레톤 -### 🧭 탐색 / 홈 +### 🧭 탐색 / 큐레이팅 / 홈 - 큐레이팅된 홈 피드 +- **흥미 기반 배틀 추천**(큐레이팅 화면) — `GET /battles/{id}/recommendations/interesting` - 카테고리·태그 탐색 - 토픽 검색 @@ -83,6 +86,8 @@ Picke-iOS/ │ ├── Presentation/ # 🎨 UI Layer │ │ ├── Auth/ # 로그인 / 코디네이터 / Toast │ │ ├── Home/ # 홈 피드 / 큐레이팅 / 스켈레톤 +│ │ ├── Chat/ # 사전·사후 투표 / 채팅방 / 관점·대댓글 / 큐레이팅 (ChatCoordinator) +│ │ ├── Hifi/ # Hi-Fi 프로토타입 모듈 │ │ ├── MainTab/ # 탭 라우팅 / GNB │ │ ├── Splash/ # 스플래시 │ │ └── Presentation/ # 공통 프레젠테이션 유틸 @@ -106,7 +111,7 @@ Picke-iOS/ │ │ └── ThirdPartys/ # AsyncMoya / WeaveDI 등 SPM 재노출 │ │ │ └── Shared/ # 🔧 Shared Layer -│ ├── DesignSystem/ # 공통 UI / 컬러 / 이미지 / Toast +│ ├── DesignSystem/ # 공통 UI / 컬러 토큰 / 이미지 / Toast / Floating 배너 / AudioPlayer / 팝업 │ ├── Shared/ # 공유 모델·확장 │ ├── ThirdParty/ # 써드파티 래퍼 │ └── Utill/ # 공통 유틸리티