diff --git a/AGENTS.md b/AGENTS.md index 4bcfbdd7..4e6224aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -498,6 +498,39 @@ public struct PreVoteFeature { - 파일 위치: `Projects/Presentation/<모듈>/Sources/<도메인>/Model/.swift` - 레퍼런스: `Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift`, `Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift` +#### 🧰 공통 유틸 — `Utill` 모듈 + `Type+.swift` 네이밍 (필수) + +도메인/화면에 종속되지 않는 **순수 유틸** (숫자 포맷, 문자열 분할, 날짜 변환 등) 은 Feature 안에 private helper 로 두지 말고 **`Shared/Utill` 모듈에 타입 확장**으로 분리한다. 여러 모듈(Chat / Home / Auth / Hifi 등)에서 공용으로 쓴다. + +```swift +// ✅ 올바른 패턴 — Utill 모듈의 타입 확장. 파일명은 `Type+.swift` (기능 접미사 없음, 한 타입당 한 파일) +// Projects/Shared/Utill/Sources/Extension/Int+.swift +public extension Int { + var decimalFormatted: String { ... } // 1340 → "1,340" +} + +// Projects/Shared/Utill/Sources/Extension/String+.swift +public extension String { + func splitIntoSentences() -> [String] { ... } +} + +// 사용처: import Utill 후 그대로 +import Utill +let count = likeCount.decimalFormatted +let lines = script.text.splitIntoSentences() + +// ❌ 금지 — Feature/View 안에 private static helper 로 중복 선언 +private static func formattedCount(_ n: Int) -> String { ... } // ← Utill 로 이동 +private static func splitSentences(_ t: String) -> [String] { ... } // ← Utill 로 이동 +``` + +규칙: +- **Extension 파일 네이밍은 항상 `Type+.swift`** — 확장 대상 타입 + `+` 만 (기능 접미사 없음). 한 타입 확장은 한 파일에 모은다 (`Int+.swift`, `String+.swift`, `UUID+.swift`, `Color+.swift`, `View+.swift`). 기존 `ShapeStyle+.swift`, `UIColor+.swift` 와 동일 규칙. +- 순수 유틸 (Foundation 만 의존, UI/도메인 무관) → `Projects/Shared/Utill/Sources/Extension/` 에 둔다 +- 쓰는 모듈은 `.Shared(implements: .Utill)` 의존성 추가 후 `import Utill` +- DesignSystem 전용 확장(컬러/폰트/모디파이어)은 DesignSystem 모듈에 두되 파일명은 동일하게 `Type+.swift` +- 레퍼런스: `Projects/Shared/Utill/Sources/Extension/Int+.swift`, `String+.swift`, `UUID+.swift` + #### ⚡ AsyncAction — `Result { try await }` + `mapError` + 단일 `Response` Inner 액션 `do/catch + 별도 Loaded / Failed 액션` 분리하지 말고, `Result` 로 감싸서 단일 `xxxResponse(Result)` Inner 액션으로 보낸다. State 캡쳐는 `[키 = state.xxx]` 형태. diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 21903a25..22c142ba 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -25,12 +25,12 @@ public extension ModulePath { case MainTab case Home case Chat - + case Hifi + case Web + case Battle public static let name: String = "Presentation" - case Hifi - case Web } } diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index c6572b46..a8fc4724 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -236,7 +236,7 @@ public struct AppReducer: Sendable { private func handleScopeNavigation(action: ScopeAction) -> Effect { switch action { case .splash(.view(.onAppear)): - return .send(.view(.presentAuth)) + return .none case .splash(.delegate(.presentAuth)): return .run { send in diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index 849ddae3..e14a3e56 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -6,6 +6,7 @@ import Foundation public enum BattleAPI { + case today case detail(battleId: Int) case preVote(battleId: Int) case postVote(battleId: Int) @@ -17,6 +18,8 @@ public enum BattleAPI { public var description: String { switch self { + case .today: + return "today" case let .detail(battleId): return "\(battleId)" case let .preVote(battleId): diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift index 66e67cbf..85a25f48 100644 --- a/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleDetailDataDTO.swift @@ -35,7 +35,8 @@ public struct BattleOptionDTO: Decodable { public let stance: String public let representative: String public let imageUrl: String - public let tags: [BattleTagDTO] + // today 배틀 옵션 응답엔 tags 가 없으므로 옵셔널. + public let tags: [BattleTagDTO]? } public struct BattleTagDTO: Decodable { diff --git a/Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift new file mode 100644 index 00000000..8a5364c8 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/TodayBattleDataDTO.swift @@ -0,0 +1,15 @@ +// +// TodayBattleDataDTO.swift +// Model +// +// `GET /api/v1/battles/today` 응답 DTO. 아이템은 BattleInfoDTO 재사용. +// + +import Foundation + +public struct TodayBattlePageDataDTO: Decodable { + public let items: [BattleInfoDTO] + public let totalCount: Int +} + +public typealias TodayBattlePageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift index 9dfead44..deaa9ba5 100644 --- a/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleDetailDataDTO+.swift @@ -46,7 +46,7 @@ public extension BattleOptionDTO { stance: stance, representative: representative, imageUrl: imageUrl, - tags: tags.map { $0.toDomain() } + tags: (tags ?? []).map { $0.toDomain() } ) } } diff --git a/Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift new file mode 100644 index 00000000..b1d619a9 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/TodayBattleDataDTO+.swift @@ -0,0 +1,16 @@ +// +// TodayBattleDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension TodayBattlePageDataDTO { + func toDomain() -> TodayBattlePage { + TodayBattlePage( + items: items.map { $0.toDomain() }, + totalCount: totalCount + ) + } +} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index f0b08674..d4aa8687 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -24,6 +24,18 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { self.provider = provider } + public func fetchTodayBattles() async throws -> TodayBattlePage { + let dto: TodayBattlePageResponseDTO = try await provider.request(.today) + + guard let data = dto.data else { + let message = dto.error?.message ?? "오늘의 배틀 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty todayBattles payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } + public func fetchBattle(battleId: Int) async throws -> BattleDetail { let dto: BattleDetailResponseDTO = try await provider.request( .detail(battleId: battleId) diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index 58f9c096..a77e3965 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -11,6 +11,7 @@ import Foundations import AsyncMoya public enum BattleService { + case today case detail(battleId: Int) case preVote(battleId: Int, body: PreVoteRequest) case postVote(battleId: Int, body: PreVoteRequest) @@ -29,6 +30,8 @@ extension BattleService: BaseTargetType { public var urlPath: String { switch self { + case .today: + return BattleAPI.today.description case let .detail(battleId): return BattleAPI.detail(battleId: battleId).description case let .preVote(battleId, _): @@ -54,7 +57,7 @@ extension BattleService: BaseTargetType { public var method: Moya.Method { switch self { - case .detail, .scenario, .voteStats, .perspectives, .myPerspective, .recommendations: + case .today, .detail, .scenario, .voteStats, .perspectives, .myPerspective, .recommendations: return .get case .preVote, .postVote, .createPerspective: return .post @@ -63,6 +66,8 @@ extension BattleService: BaseTargetType { public var parameters: [String: Any]? { switch self { + case .today: + return nil case .detail: return nil case let .preVote(_, body): diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index 3627f339..db3452c3 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -8,6 +8,7 @@ import Foundation import WeaveDI public protocol BattleInterface: Sendable { + func fetchTodayBattles() async throws -> TodayBattlePage func fetchBattle(battleId: Int) async throws -> BattleDetail func submitPreVote( battleId: Int, diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 4e618cc6..23eced77 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -9,6 +9,10 @@ import Foundation public struct DefaultBattleRepositoryImpl: BattleInterface { public init() {} + public func fetchTodayBattles() async throws -> TodayBattlePage { + TodayBattlePage(items: [], totalCount: 0) + } + public func fetchBattle(battleId _: Int) async throws -> BattleDetail { BattleDetail( battleInfo: BattleInfo( diff --git a/Projects/Domain/Entity/Sources/Battle/TodayBattlePage.swift b/Projects/Domain/Entity/Sources/Battle/TodayBattlePage.swift new file mode 100644 index 00000000..7f3cd930 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Battle/TodayBattlePage.swift @@ -0,0 +1,21 @@ +// +// TodayBattlePage.swift +// Entity +// +// `GET /api/v1/battles/today` 응답 도메인 모델. 아이템은 BattleInfo 재사용. +// + +import Foundation + +public struct TodayBattlePage: Equatable { + public let items: [BattleInfo] + public let totalCount: Int + + public init( + items: [BattleInfo], + totalCount: Int + ) { + self.items = items + self.totalCount = totalCount + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift new file mode 100644 index 00000000..ab2522c5 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift @@ -0,0 +1,22 @@ +// +// BattleScenario+Timeline.swift +// Entity +// +// 시나리오 타임라인 계산. +// + +import Foundation + +public extension BattleScenario { + /// 노드 시작 시간(초) = 노드 내 대사 startTimeMs 최소값. + func nodeStartTime(for nodeId: Int) -> TimeInterval { + guard let node = nodes.first(where: { $0.nodeId == nodeId }) else { return 0 } + return TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 + } + + /// 노드 종료 시간(초) = 노드 시작 + audioDuration. + func nodeEndTime(for node: ScenarioNode) -> TimeInterval { + let start = TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 + return start + TimeInterval(node.audioDuration) + } +} diff --git a/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift b/Projects/Domain/Entity/Sources/Share/ShareContent.swift similarity index 96% rename from Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift rename to Projects/Domain/Entity/Sources/Share/ShareContent.swift index d63224b4..920f7cab 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Model/ShareContent.swift +++ b/Projects/Domain/Entity/Sources/Share/ShareContent.swift @@ -1,8 +1,9 @@ // // ShareContent.swift -// Chat +// Entity // // 공유 시트에 실어 보낼 컨텐츠 묶음. 카드 스냅샷(우선) + 썸네일(fallback) + 본문 텍스트 + URL. +// (PreVote / 오늘의 배틀 등 공용) // import Foundation diff --git a/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift b/Projects/Domain/Entity/Sources/Share/ShareItem.swift similarity index 87% rename from Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift rename to Projects/Domain/Entity/Sources/Share/ShareItem.swift index c2b951fb..1f66c2da 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Model/ShareItem.swift +++ b/Projects/Domain/Entity/Sources/Share/ShareItem.swift @@ -1,8 +1,8 @@ // // ShareItem.swift -// Chat +// Entity // -// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩한다. +// 공유 시트 트리거. `.sheet(item:)` 에 바로 바인딩한다. (PreVote / 오늘의 배틀 등 공용) // import Foundation diff --git a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift index 30476cb9..b3bb82c3 100644 --- a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift @@ -15,6 +15,10 @@ public struct BattleUseCaseImpl: BattleInterface { public init() {} + public func fetchTodayBattles() async throws -> TodayBattlePage { + return try await battleRepository.fetchTodayBattles() + } + public func fetchBattle(battleId: Int) async throws -> BattleDetail { return try await battleRepository.fetchBattle(battleId: battleId) } diff --git a/Projects/Presentation/Battle/Project.swift b/Projects/Presentation/Battle/Project.swift new file mode 100644 index 00000000..8f52e705 --- /dev/null +++ b/Projects/Presentation/Battle/Project.swift @@ -0,0 +1,20 @@ +import DependencyPackagePlugin +import DependencyPlugin +import Foundation +import ProjectDescription +import ProjectTemplatePlugin + +let project = Project.makeAppModule( + name: "Battle", + bundleId: .appBundleID(name: ".Battle"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Presentation(implements: .Chat), + .Shared(implements: .Shared), + .Domain(implements: .UseCase), + .SPM.composableArchitecture, + .SPM.tcaFlow, + ], + sources: ["Sources/**"] +) diff --git a/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift b/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift new file mode 100644 index 00000000..d124a445 --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Coordinator/Reducer/BattleCoordinator.swift @@ -0,0 +1,118 @@ +// +// BattleCoordinator.swift +// Battle +// +// 빠른 배틀 탭 코디네이터. 루트는 BattleFeature, 배틀 진입 시 ChatCoordinator 로 push. +// + +import Foundation + +import Chat +import ComposableArchitecture +import TCAFlow + +@FlowCoordinator(screen: "BattleScreen", navigation: true) +public struct BattleCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init() { + routes = [.root(.battle(.init()), embedInNavigationView: true)] + } + } + + @CasePathable + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + } + + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + public enum AsyncAction: Equatable {} + public enum InnerAction: Equatable {} + public enum NavigationAction: Equatable {} + + func handleRoute( + state: inout State, + action: Action + ) -> Effect { + switch action { + case let .router(routeAction): + routerAction(state: &state, action: routeAction) + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + case .async, .inner, .navigation: + .none + } + } +} + +extension BattleCoordinator { + private func routerAction( + state: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + // 오늘의 배틀에서 선택 후 입장 → ChatRoom(채팅방) 직접 진입. + case let .routeAction(_, action: .battle(.delegate(.openBattle(battleId)))): + state.routes.push(.chatRoom(.init(battleId: battleId))) + return .none + + case .routeAction(_, action: .chatRoom(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 채팅방 다 들으면 → ChatCoordinator(초기 PreVote) 흐름으로 연결. + case let .routeAction(_, action: .chatRoom(.delegate(.requestFinalVote(battleId)))): + state.routes.push(.chat(.init(battleId: battleId))) + return .none + + case .routeAction(_, action: .chat(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case .routeAction(_, action: .chat(.delegate(.popToRoot))): + return .send(.view(.backToRootAction)) + + default: + return .none + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backAction: + state.routes.goBack() + return .none + case .backToRootAction: + state.routes.goBackToRoot() + return .none + } + } +} + +// swiftformat:disable extensionAccessControl +extension BattleCoordinator { + @Reducer + public enum BattleScreen { + case battle(BattleFeature) + case chatRoom(ChatRoomFeature) + case chat(ChatCoordinator) + } +} + +// swiftformat:enable extensionAccessControl + +extension BattleCoordinator.BattleScreen.State: Equatable {} diff --git a/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift b/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift new file mode 100644 index 00000000..53318440 --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Coordinator/View/BattleCoordinatorView.swift @@ -0,0 +1,33 @@ +// +// BattleCoordinatorView.swift +// Battle +// + +import Foundation + +import SwiftUI + +import Chat +import ComposableArchitecture +import TCAFlow + +public struct BattleCoordinatorView: View { + @Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in + switch screen.case { + case let .battle(battleStore): + BattleView(store: battleStore) + case let .chatRoom(chatRoomStore): + ChatRoomView(store: chatRoomStore) + case let .chat(chatStore): + ChatCoordinatorView(store: chatStore) + } + } + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift b/Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift new file mode 100644 index 00000000..bac61548 --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Main/Model/DailyBattle.swift @@ -0,0 +1,77 @@ +// +// DailyBattle.swift +// Battle +// +// 오늘의 배틀 화면 모델 — picke.pen `오늘의 배틀`. `BattleInfo` 에서 매핑. +// + +import Foundation + +import Entity +import Utill + +public struct DailyBattle: Equatable, Identifiable { + public var battleId: Int + public var imageURL: String? + public var tags: [String] + public var title: String + public var question: String + public var durationText: String + public var options: [Option] + + public var id: Int { battleId } + + public init( + battleId: Int, + imageURL: String?, + tags: [String], + title: String, + question: String, + durationText: String, + options: [Option] + ) { + self.battleId = battleId + self.imageURL = imageURL + self.tags = tags + self.title = title + self.question = question + self.durationText = durationText + self.options = options + } + + /// 세로 카드 한 장 (대표자 / 입장 / 인용구). + public struct Option: Equatable, Identifiable { + public let id: Int + public var representative: String + public var stance: String + public var quote: String + + public init( + id: Int, + representative: String, + stance: String, + quote: String + ) { + self.id = id + self.representative = representative + self.stance = stance + self.quote = quote + } + } + + /// 서버 `BattleInfo`(오늘의 배틀 아이템) → 화면 모델 매핑. + public static func from(_ info: BattleInfo) -> DailyBattle { + DailyBattle( + battleId: info.battleId, + imageURL: info.thumbnailUrl.isEmpty ? nil : info.thumbnailUrl, + tags: info.tags.map(\.name), + title: info.title, + question: info.summary, + durationText: info.audioDuration.durationText, + options: info.options.prefix(2).map { + // API title = 짧은 입장 라벨("선하다"), API stance = 설명 문장(긴 인용구). + Option(id: $0.optionId, representative: $0.representative, stance: $0.title, quote: $0.stance) + } + ) + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift new file mode 100644 index 00000000..53c788a5 --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift @@ -0,0 +1,179 @@ +// +// BattleFeature.swift +// Battle +// +// 빠른 배틀 탭 루트 기능 — picke.pen `오늘의 배틀`. +// 배경 이미지 + 제목/소요시간 + VS 선택지 + "배틀 입장하기". +// GET /api/v1/battles (예정) — 현재는 목 데이터. +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct BattleFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + /// 오늘의 배틀 목록 — 세로 스크롤로 다음 배틀 노출. 비어있으면 "없음". + public var battles: [DailyBattle] = [] + /// 배틀별 선택한 옵션 (battleId → optionId). 미선택 시 "배틀 입장하기" 비활성. + public var selectedOptionByBattle: [Int: Int] = [:] + /// 이미 입장(투표)한 배틀 — 복귀 시 옵션 변경 비활성화. + public var votedBattleIds: Set = [] + /// 시스템 공유 시트 트리거. + public var shareItem: ShareItem? + + public init() {} + + public func selectedOption(for battleId: Int) -> Int? { + selectedOptionByBattle[battleId] + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case shareTapped(battleId: Int) + case optionTapped(battleId: Int, optionId: Int) + case enterBattleTapped(battleId: Int) + } + + public enum AsyncAction: Equatable { + case fetchRequested + } + + public enum InnerAction: Equatable { + case todayResponse(Result) + } + + nonisolated enum CancelID: Hashable { + case fetchToday + } + + @Dependency(\.battleUseCase) private var battleUseCase + + public enum DelegateAction: Equatable { + /// 배틀 입장 → 채팅방 진입 + case openBattle(battleId: Int) + /// 상단 백탭 → 홈 탭으로 복귀 + case backToHome + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + 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 .delegate: + return .none + } + } + } +} + +extension BattleFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetchRequested)) + + case .backTapped: + return .send(.delegate(.backToHome)) + + case let .shareTapped(battleId): + guard let battle = state.battles.first(where: { $0.battleId == battleId }) else { return .none } + let text = [ + battle.title, + battle.question, + battle.tags.map { "#\($0)" }.joined(separator: " "), + ] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + var items: [Any] = [text] + if let urlString = battle.imageURL, let url = URL(string: urlString) { items.append(url) } + state.shareItem = ShareItem(items: items) + return .none + + case let .optionTapped(battleId, optionId): + // 같은 옵션 재탭 시 해제, 아니면 선택. + if state.selectedOptionByBattle[battleId] == optionId { + state.selectedOptionByBattle[battleId] = nil + } else { + state.selectedOptionByBattle[battleId] = optionId + } + return .none + + case let .enterBattleTapped(battleId): + // 선택해야만 입장 가능. 입장 시 투표 확정 → 복귀 시 옵션 변경 비활성화. + guard state.selectedOptionByBattle[battleId] != nil else { return .none } + state.votedBattleIds.insert(battleId) + return .send(.delegate(.openBattle(battleId: battleId))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchRequested: + state.isLoading = true + return .run { [useCase = battleUseCase] send in + let result = await Result { + try await useCase.fetchTodayBattles() + } + .mapError(BattleError.from) + return await send(.inner(.todayResponse(result))) + } + .cancellable(id: CancelID.fetchToday, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .todayResponse(result): + state.isLoading = false + switch result { + case let .success(page): + state.battles = page.items.map(DailyBattle.from) + case let .failure(error): + state.battles = [] + Log.error("[BattleFeature] fetchTodayBattles failed: \(error.localizedDescription)") + } + return .none + } + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift new file mode 100644 index 00000000..349b25aa --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift @@ -0,0 +1,348 @@ +// +// BattleView.swift +// Battle +// +// 빠른 배틀 탭 루트 UI — picke.pen `오늘의 배틀`. +// 배경 이미지 + 하단 그라데이션 + 태그/제목/질문/소요시간 + 세로 VS 선택지 + "배틀 입장하기". +// 오늘의 배틀이 여러 건이면 세로 스크롤(페이징)로 다음 배틀 노출. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Kingfisher + +@ViewAction(for: BattleFeature.self) +public struct BattleView: View { + @Bindable public var store: StoreOf + /// 현재 보이는 배틀 (세로 페이징) — 공유 시 대상 식별. + @State private var currentBattleId: Int? + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + // ZStack 은 safe-area 존중(ignoresSafeArea 미적용) → appBar 가 노치 아래 + 항상 최상단 탭 가능. + // 배경/pager 는 각자 내부에서 ignoresSafeArea 로 풀블리드 유지. + ZStack { + Color.neutral900.ignoresSafeArea() + + if store.isLoading { + BattleSkeletonView() + } else if store.battles.isEmpty { + emptyState() + } else { + pager() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + // PreVoteView 패턴: 앱바는 overlay 로 올려 항상 최상단·탭 가능하게. + .overlay(alignment: .top) { + appBar() + .padding(.top, 10) + .contentShape(Rectangle()) + .zIndex(10) + } + .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + // 데이터 로드 후 첫 페이지를 현재 배틀로 초기화 (스크롤 전엔 scrollPosition 이 nil). + .onChange(of: store.battles.map(\.id)) { _, ids in + if currentBattleId == nil { currentBattleId = ids.first } + } + .sheet(item: $store.shareItem) { item in + ShareSheet(items: item.items) + .presentationDetents([.fraction(0.5)]) + } + } +} + +// MARK: - Pager (세로 페이징) + +private extension BattleView { + @ViewBuilder + func pager() -> some View { + GeometryReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(store.battles) { battle in + battlePage(battle, size: proxy.size) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .scrollPosition(id: $currentBattleId) + } + .ignoresSafeArea() + } + + // PreVoteView 패턴: 배경 이미지와 콘텐츠 각각에 proxy 폭을 명시해 폭을 제약한다. + @ViewBuilder + func battlePage(_ battle: DailyBattle, size: CGSize) -> some View { + ZStack(alignment: .bottom) { + backgroundImage(battle.imageURL) + .frame(width: size.width, height: size.height) + + bottomContent(battle) + .frame(width: size.width) + } + .frame(width: size.width, height: size.height) + } +} + +// MARK: - Background + +private extension BattleView { + @ViewBuilder + func backgroundImage(_ imageURL: String?) -> some View { + ZStack { + if let imageURL, let url = URL(string: imageURL) { + KFImage(url) + .placeholder { Color.neutral800 } + .resizable() + .scaledToFill() + } else { + Color.neutral800 + } + + LinearGradient( + stops: [ + .init(color: .neutral900.opacity(0), location: 0), + .init(color: .neutral900.opacity(0.85), location: 0.55), + .init(color: .neutral900, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } +} + +// MARK: - App Bar + +private extension BattleView { + @ViewBuilder + func appBar() -> some View { + HStack { + Button { send(.backTapped) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.beige50) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + + Spacer() + + // 데이터 없을 땐 공유 숨김. + if let battleId = currentBattleId ?? store.battles.first?.battleId { + Button { send(.shareTapped(battleId: battleId)) } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.beige50) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + } +} + +// MARK: - Bottom Content + +private extension BattleView { + @ViewBuilder + func bottomContent(_ battle: DailyBattle) -> some View { + VStack(spacing: 32) { + contentSection(battle) + vsOptions(battle) + enterButton(battle) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.bottom, 40) + } + + @ViewBuilder + func contentSection(_ battle: DailyBattle) -> some View { + VStack(spacing: 16) { + VStack(spacing: 12) { + VStack(spacing: 20) { + tagsRow(battle.tags) + Text(battle.title) + .pretendardFont(family: .Bold, size: 24) + .foregroundStyle(.beige50) + .kerning(-0.6) + .multilineTextAlignment(.center) + .lineSpacing(24 * 0.18) + .lineLimit(2) + .minimumScaleFactor(0.85) + .fixedSize(horizontal: false, vertical: true) + } + + if !battle.question.isEmpty { + Text(battle.question) + .pretendardFont(family: .Regular, size: 14) + .foregroundStyle(.gray300) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + + durationBadge(battle.durationText) + } + } + + @ViewBuilder + func tagsRow(_ tags: [String]) -> some View { + HStack(spacing: 9) { + ForEach(Array(tags.enumerated()), id: \.offset) { _, tag in + Text("#\(tag)") + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + } + } + + @ViewBuilder + func durationBadge(_ text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: "clock") + .font(.system(size: 11, weight: .semibold)) + Text(text) + .pretendardFont(family: .SemiBold, size: 12) + } + .foregroundStyle(.gray300) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.gray500, lineWidth: 1) + ) + } +} + +// MARK: - VS Options (세로 카드 + VS 배지) + +private extension BattleView { + @ViewBuilder + func vsOptions(_ battle: DailyBattle) -> some View { + let options = battle.options + let selectedId = store.selectedOptionByBattle[battle.battleId] + // 이미 입장(투표)한 배틀이면 옵션 변경 비활성화. + let isVoted = store.votedBattleIds.contains(battle.battleId) + ZStack { + VStack(spacing: 12) { + ForEach(options) { option in + optionCard(option, isSelected: selectedId == option.id) { + send(.optionTapped(battleId: battle.battleId, optionId: option.id)) + } + } + } + .disabled(isVoted) + + if options.count > 1 { + vsBadge() + } + } + } + + /// 선택 시 picke.pen 강조(neutral900 배경 + 골드 테두리), 미선택은 gray700. + @ViewBuilder + func optionCard( + _ option: DailyBattle.Option, + isSelected: Bool, + onTap: @escaping () -> Void + ) -> some View { + Button(action: onTap) { + VStack(spacing: 0) { + Text(option.representative) + .pretendardFont(family: .Bold, size: 10) + .foregroundStyle(.secondary500) + .kerning(1.5) + .padding(.bottom, 8) + + Text(option.stance) + .pretendardFont(family: .Bold, size: 18) + .foregroundStyle(.beige50) + .kerning(-0.45) + .multilineTextAlignment(.center) + .lineLimit(1) + .minimumScaleFactor(0.8) + .padding(.horizontal, 20) + + if !option.quote.isEmpty { + Text(option.quote) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.top, 10) + .padding(.horizontal, 20) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(isSelected ? Color.neutral900 : Color.gray700, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(isSelected ? .secondary500 : .clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func vsBadge() -> some View { + Text("VS") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral900) + .frame(width: 40, height: 40) + .background(.secondary200, in: Circle()) + .overlay(Circle().stroke(.beige50, lineWidth: 1.5)) + } +} + +// MARK: - CTA + +private extension BattleView { + @ViewBuilder + func enterButton(_ battle: DailyBattle) -> some View { + CustomButton( + action: { send(.enterBattleTapped(battleId: battle.battleId)) }, + title: "배틀 입장하기", + config: CustomButtonConfig.primary(.large, height: 52), + isEnable: store.selectedOptionByBattle[battle.battleId] != nil + ) + } +} + +// MARK: - Empty / Skeleton + +private extension BattleView { + @ViewBuilder + func emptyState() -> some View { + VStack(spacing: 8) { + Image(asset: .noDataLogo) + .resizable() + .scaledToFit() + .frame(width: 135, height: 90) + + Text("오늘의 배틀이 없습니다") + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.beige300) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift b/Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift new file mode 100644 index 00000000..7e208384 --- /dev/null +++ b/Projects/Presentation/Battle/Sources/Main/View/Components/BattleSkeletonView.swift @@ -0,0 +1,77 @@ +// +// BattleSkeletonView.swift +// Battle +// +// 오늘의 배틀 로딩 스켈레톤 — 다크 배경에 맞춘 어두운 shimmer. +// + +import SwiftUI + +import DesignSystem + +struct BattleSkeletonView: View { + var body: some View { + VStack(spacing: 32) { + VStack(spacing: 16) { + block(width: 120, height: 18) + block(width: 260, height: 28) + block(width: 220, height: 18) + block(width: 90, height: 28) + } + + VStack(spacing: 12) { + block(maxWidth: true, height: 96) + block(maxWidth: true, height: 96) + } + + block(maxWidth: true, height: 52) + } + .padding(.horizontal, 16) + .padding(.bottom, 40) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + + @ViewBuilder + private func block( + width: CGFloat? = nil, + maxWidth: Bool = false, + height: CGFloat + ) -> some View { + DarkSkeletonBlock(cornerRadius: 2) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} + +/// 다크 배경용 skeleton 블록 (어두운 base + 옅은 흰색 shimmer). +private struct DarkSkeletonBlock: View { + let cornerRadius: CGFloat + + @State private var phase: CGFloat = -1 + + private let baseColor = Color.white.opacity(0.08) + private let shimmerColor = Color.white.opacity(0.20) + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(baseColor) + .overlay { + LinearGradient( + stops: [ + .init(color: shimmerColor.opacity(0), location: 0), + .init(color: shimmerColor, location: 0.5), + .init(color: shimmerColor.opacity(0), location: 1), + ], + startPoint: UnitPoint(x: phase, y: 0.5), + endPoint: UnitPoint(x: phase + 1, y: 0.5) + ) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 2 + } + } + } +} diff --git a/Projects/Presentation/Battle/Tests/Sources/BattleTests.swift b/Projects/Presentation/Battle/Tests/Sources/BattleTests.swift new file mode 100644 index 00000000..4a2f4c62 --- /dev/null +++ b/Projects/Presentation/Battle/Tests/Sources/BattleTests.swift @@ -0,0 +1,27 @@ +// +// BattleTests.swift +// Presentation.BattleTests +// +// Created by Roy on 2026-06-05. +// + +import Testing +@testable import Battle + +struct BattleTests { + + @Test + func battleExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func battleLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} + diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index 0a78c8a2..ac3132cf 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -13,6 +13,7 @@ import DomainInterface import Entity import LogMacro import UseCase +import Utill @Reducer public struct ChatRoomFeature { @@ -55,35 +56,63 @@ public struct ChatRoomFeature { public var messages: [ChatMessage] { guard let scenario else { return bundle.messages } let nodes = visibleNodes(in: scenario) - return nodes.flatMap { node in - node.scripts.map { script in - ChatMessage( - messageId: Self.scriptUUID(scriptId: script.scriptId), - speaker: speaker(for: script, in: scenario), - text: script.text, - startTimeMs: script.startTimeMs - ) + let currentMs = Int(currentTime * 1000) + return nodes.flatMap { node -> [ChatMessage] in + let scripts = node.scripts + // 대사별 startTimeMs 가 구분되면 그 값을, 모두 같거나(0) 평평하면 노드 오디오 구간에 균등 분배. + let distinctStarts = Set(scripts.map(\.startTimeMs)).count + let nodeStartMs = scripts.map(\.startTimeMs).min() ?? 0 + let nodeDurationMs = node.audioDuration * 1000 + let count = scripts.count + + func revealStart(_ index: Int) -> Int { + if distinctStarts > 1 { return scripts[index].startTimeMs } + if count > 1 { return nodeStartMs + nodeDurationMs * index / count } + return nodeStartMs } - } - } - /// 같은 scriptId 면 동일한 UUID 를 반환해 ForEach 의 id 가 매 렌더링마다 - /// 흔들리지 않도록 한다 (자동 스크롤 target 안정화). - private static func scriptUUID(scriptId: Int) -> UUID { - let hex = String(format: "%012X", scriptId) - return UUID(uuidString: "00000000-0000-0000-0000-\(hex)") ?? UUID() + return scripts.enumerated().flatMap { index, script -> [ChatMessage] in + let scriptStart = revealStart(index) + guard currentMs >= scriptStart else { return [] } + let scriptEnd = index + 1 < count ? revealStart(index + 1) : nodeStartMs + nodeDurationMs + let windowMs = max(1, scriptEnd - scriptStart) + let messageSpeaker = speaker(for: script, in: scenario) + + // 나레이션/클로징(center)·발언자(좌/우) 모두 문장마다 개별 말풍선. + // 문장 시작 시점은 글자수 비례로 분배(긴 문장=더 긴 시간) → 싱크. + let sentences = script.text.splitIntoSentences() + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard sentences.count > 1 else { + return [ChatMessage( + messageId: UUID.deterministic(script.scriptId, 0), + speaker: messageSpeaker, + text: script.text, + startTimeMs: scriptStart + )] + } + let totalChars = max(1, sentences.reduce(0) { $0 + $1.count }) + var charsBefore = 0 + var result: [ChatMessage] = [] + for (sentenceIndex, sentence) in sentences.enumerated() { + let sentenceStart = scriptStart + windowMs * charsBefore / totalChars + charsBefore += sentence.count + guard currentMs >= sentenceStart else { break } + result.append(ChatMessage( + messageId: UUID.deterministic(script.scriptId, sentenceIndex), + speaker: messageSpeaker, + text: sentence, + startTimeMs: sentenceStart + )) + } + return result + } + } } - /// 현재 재생 시점에 해당하는 메시지 id. `currentTime` 이상의 startTimeMs 를 - /// 가지지 않은 마지막 메시지를 활성으로 본다. + /// 현재 재생 중(가장 최근 노출된) 메시지 id. 문장 단위 노출이라 마지막 노출 버블이 활성. public var activeMessageId: UUID? { - let currentMs = Int(currentTime * 1000) - var active: ChatMessage? - for message in messages { - guard let start = message.startTimeMs else { continue } - if start <= currentMs { active = message } else { break } - } - return active?.id ?? messages.first?.id + messages.last?.id } public var audioUrl: String? { @@ -94,48 +123,6 @@ public struct ChatRoomFeature { public var canScrub: Bool { hasFinishedListening } - private func speaker( - for script: ScenarioScript, - in scenario: BattleScenario - ) -> ChatSpeaker { - switch script.speakerType { - case .a: - return speaker(label: "A", side: .left, fallbackName: script.speakerName, in: scenario) - case .b: - return speaker(label: "B", side: .right, fallbackName: script.speakerName, in: scenario) - case .narrator: - return ChatSpeaker(name: script.speakerName, side: .center) - case .philosopher, .unknown: - if let philosopher = scenario.philosophers.first(where: { $0.name == script.speakerName }) { - let side: ChatSpeakerSide = philosopher.label == "B" ? .right : .left - return ChatSpeaker( - label: philosopher.label, - name: philosopher.name, - imageURL: philosopher.imageUrl, - side: side - ) - } - return ChatSpeaker(name: script.speakerName, side: .center) - } - } - - private func speaker( - label: String, - side: ChatSpeakerSide, - fallbackName: String, - in scenario: BattleScenario - ) -> ChatSpeaker { - guard let philosopher = scenario.philosophers.first(where: { $0.label == label }) else { - return ChatSpeaker(label: label, name: fallbackName, side: side) - } - return ChatSpeaker( - label: philosopher.label, - name: philosopher.name, - imageURL: philosopher.imageUrl, - side: side - ) - } - public var currentNode: ScenarioNode? { guard let scenario else { return nil } let target = currentNodeId ?? scenario.startNodeId @@ -170,21 +157,12 @@ public struct ChatRoomFeature { "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 + scenario?.nodeStartTime(for: nodeId) ?? 0 } public func nodeEndTime(for node: ScenarioNode) -> TimeInterval { - let start = TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 - return start + TimeInterval(node.audioDuration) + scenario?.nodeEndTime(for: node) ?? 0 } } @@ -268,6 +246,67 @@ public struct ChatRoomFeature { } } +private extension ChatRoomFeature.State { + 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 } + } + } + + func speaker( + for script: ScenarioScript, + in scenario: BattleScenario + ) -> ChatSpeaker { + switch script.speakerType { + case .a: + return speaker( + label: "A", + side: .left, + fallbackName: script.speakerName, + in: scenario + ) + case .b: + return speaker( + label: "B", + side: .right, + fallbackName: script.speakerName, + in: scenario + ) + case .narrator: + return ChatSpeaker(name: script.speakerName, side: .center) + case .philosopher, .unknown: + if let philosopher = scenario.philosophers.first(where: { $0.name == script.speakerName }) { + let side: ChatSpeakerSide = philosopher.label == "B" ? .right : .left + return ChatSpeaker( + label: philosopher.label, + name: philosopher.name, + imageURL: philosopher.imageUrl, + side: side + ) + } + return ChatSpeaker(name: script.speakerName, side: .center) + } + } + + func speaker( + label: String, + side: ChatSpeakerSide, + fallbackName: String, + in scenario: BattleScenario + ) -> ChatSpeaker { + guard let philosopher = scenario.philosophers.first(where: { $0.label == label }) else { + return ChatSpeaker(label: label, name: fallbackName, side: side) + } + return ChatSpeaker( + label: philosopher.label, + name: philosopher.name, + imageURL: philosopher.imageUrl, + side: side + ) + } +} + extension ChatRoomFeature { private func handleViewAction( state: inout State, diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift index d8828992..23153e1a 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/View/ChatRoomView.swift @@ -109,17 +109,19 @@ extension ChatRoomView { VStack(alignment: .leading, spacing: 20) { ForEach(groupedMessages, id: \.id) { group in messageGroup(group) - .id(group.messages.last?.id ?? group.id) + .transition(.opacity) } } .padding(.horizontal, 16) .padding(.vertical, 20) + // 대사가 한 줄씩 추가될 때 슬라이드/페이드인. + .animation(.easeInOut(duration: 0.25), value: store.messages) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: store.activeMessageId) { _, activeMessageId in guard let target = scrollTargetId(for: activeMessageId) else { return } withAnimation(.easeInOut(duration: 0.25)) { - proxy.scrollTo(target, anchor: .center) + proxy.scrollTo(target, anchor: .bottom) } } } @@ -169,6 +171,7 @@ extension ChatRoomView { VStack(spacing: 6) { ForEach(group.messages) { message in narratorBubble(text: message.text) + .id(message.id) } } .frame(maxWidth: .infinity) @@ -201,7 +204,14 @@ extension ChatRoomView { VStack(alignment: .leading, spacing: 6) { ForEach(messages) { message in - bubble(text: message.text, side: speaker.side) + let isActive = store.isPlaying && message.id == store.activeMessageId + HStack(alignment: .center, spacing: 6) { + if speaker.side == .right, isActive { waveformIcon() } + bubble(text: message.text, side: speaker.side, isActive: isActive) + .id(message.id) + if speaker.side == .left, isActive { waveformIcon() } + } + .transition(.opacity) } } } @@ -211,40 +221,48 @@ extension ChatRoomView { @ViewBuilder private func bubble( text: String, - side: ChatSpeakerSide + side: ChatSpeakerSide, + isActive: Bool = false ) -> some View { + // 재생 중인 말풍선은 흰 배경으로 강조 (테두리는 동일 유지). + let background: Color = isActive ? .beige50 : (side == .left ? .beige300 : .beige400) + let border: Color = side == .left ? .beige600 : .beige700 Text(text) .pretendardFont(family: .Regular, size: 13) - .foregroundStyle(.neutral500) + .foregroundStyle(isActive ? .neutral800 : .neutral500) .lineSpacing(13 * 0.4) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 8) .padding(.vertical, 6) - .frame(maxWidth: Metric.bubbleMaxWidth, alignment: .leading) - .background( - side == .left ? Color.beige50 : Color.beige400, - in: RoundedRectangle(cornerRadius: 2) - ) + .background(background, in: RoundedRectangle(cornerRadius: 2)) .overlay( RoundedRectangle(cornerRadius: 2) - .stroke(side == .left ? Color.beige600 : Color.beige700, lineWidth: 1) + .stroke(border, lineWidth: 1) ) } + /// 재생 중 말풍선 옆 음성 파형 아이콘. + @ViewBuilder + private func waveformIcon() -> some View { + Image(systemName: "waveform") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.primary500) + .frame(width: 24, height: 24) + } + + /// 나레이션/클로징: 박스·테두리 없이 가운데 정렬 + 살짝 기울인 이탤릭. @ViewBuilder private func narratorBubble(text: String) -> some View { Text(text) .pretendardFont(family: .Regular, size: 12) + .italic() .foregroundStyle(.neutral400) .lineSpacing(12 * 0.4) .multilineTextAlignment(.center) .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.vertical, 4) .frame(maxWidth: 280) - .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(.beige600, lineWidth: 1) - ) } private struct SpeakerGroup: Equatable, Identifiable { diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index 42169315..b7009e3b 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -530,8 +530,13 @@ extension CommentFeature { switch result { case let .success(page): let myPid = state.perspectiveId + // 진영(.a/.b) 은 옵션 label 문자열이 아니라 optionId 로 판별(서버 label 이 "A"/"B" 가 아닐 수 있음). + let optionAId = state.voteSummary.optionA.optionId let mapped = page.items.enumerated().map { idx, item -> CommentItem in var comment = CommentItem(item: item, order: idx) + if optionAId > 0 { + comment.option = item.option.optionId == optionAId ? .a : .b + } // 서버 isMyPerspective 가 누락/false 여도 내 perspectiveId 와 일치하면 내 글로 판정. if let myPid, comment.perspectiveId == myPid { comment.isMine = true @@ -594,26 +599,48 @@ extension CommentFeature { fallback: VoteSummary ) -> VoteSummary { guard stats.options.count >= 2 else { return fallback } - let a = stats.options[0] - let b = stats.options[1] + let optionA = matchedStatsOption( + in: stats.options, + fallbackOptionId: fallback.optionA.optionId, + fallbackIndex: 0 + ) + let optionB = matchedStatsOption( + in: stats.options, + fallbackOptionId: fallback.optionB.optionId, + fallbackIndex: 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 - ) + optionA: makeOptionSummary(optionA, fallback: fallback.optionA), + optionB: makeOptionSummary(optionB, fallback: fallback.optionB) + ) + } + + private func matchedStatsOption( + in options: [BattleVoteStatsOption], + fallbackOptionId: Int, + fallbackIndex: Int + ) -> BattleVoteStatsOption { + if fallbackOptionId > 0, + let matched = options.first(where: { $0.optionId == fallbackOptionId }) + { + return matched + } + return options[fallbackIndex] + } + + private func makeOptionSummary( + _ option: BattleVoteStatsOption, + fallback: VoteOptionSummary + ) -> VoteOptionSummary { + VoteOptionSummary( + // vote-stats 응답 순서가 배틀 상세와 달라도 fallback optionId 로 A/B 진영을 유지한다. + optionId: option.optionId > 0 ? option.optionId : fallback.optionId, + label: option.label ?? fallback.label, + title: option.title.isEmpty ? fallback.title : option.title, + representative: fallback.representative, + imageUrl: option.imageUrl ?? fallback.imageUrl, + percentage: option.ratio ) } } diff --git a/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift b/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift index 2bec5b91..bf6d641a 100644 --- a/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift +++ b/Projects/Presentation/Chat/Sources/Curation/View/CurationView.swift @@ -10,6 +10,7 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity +import Utill @ViewAction(for: CurationFeature.self) public struct CurationView: View { @@ -145,7 +146,7 @@ private extension CurationView { HStack(spacing: 2) { Image(systemName: "clock") .font(.system(size: 11, weight: .regular)) - Text(durationText(battle.audioDuration)) + Text(battle.audioDuration.roundedMinuteText) .pretendardFont(family: .Medium, size: 12) } .foregroundStyle(.neutral300) @@ -224,11 +225,6 @@ private extension CurationView { .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 { diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift index bf57726c..0b7f45e9 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteLayout.swift @@ -9,11 +9,10 @@ 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 contentToOptionSpacing: CGFloat = 40 static let optionCardHeight: CGFloat = 106 static let contentHorizontalPadding: CGFloat = 16 static let contentTopPadding: CGFloat = 80 @@ -22,4 +21,17 @@ enum PreVoteLayout { static let ctaHorizontalPadding: CGFloat = 16 static let contentBottomSpacing: CGFloat = ctaHeight + ctaBottomSpacing + rootContentSpacing static let snapshotWidth: CGFloat = 360 + + /// 제목·요약 길이에 따라 그라데이션 여백을 동적으로 결정(3단계). + /// 텍스트가 짧을수록 여백을 키워 콘텐츠를 아래로, 길수록 줄여 위로 끌어올린다. + static func contentGradientSpacerHeight( + titleLength: Int, + summaryLength: Int + ) -> CGFloat { + switch titleLength + summaryLength { + case ...60: 76 + case 61 ... 90: 60 + default: 52 + } + } } diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index 8d29d126..6e022776 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -52,7 +52,7 @@ public struct PreVoteView: View { .onAppear { send(.onAppear) } .sheet(item: $store.shareItem) { item in ShareSheet(items: item.items) - .presentationDetents([.fraction(0.6)]) + .presentationDetents([.fraction(0.5)]) .toolbar(.hidden, for: .navigationBar) } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) @@ -76,11 +76,15 @@ public struct PreVoteView: View { .frame(height: PreVoteLayout.contentOverlapTopOffset) Spacer() - .frame(height: PreVoteLayout.contentGradientSpacerHeight) + .frame(height: PreVoteLayout.contentGradientSpacerHeight( + titleLength: battle.titleLine1.count + battle.titleLine2.count, + summaryLength: battle.summary.count + )) contentArea(battle) } .frame(width: proxy.size.width) + .frame(minHeight: proxy.size.height, alignment: .top) } .scrollBounceBehavior(.basedOnSize) .scrollDisabled(true) @@ -91,7 +95,6 @@ public struct PreVoteView: View { PreVoteSkeletonView() } } - } // MARK: - Background @@ -174,14 +177,16 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder private func contentArea(_ battle: PreVoteBattle) -> some View { - VStack(spacing: PreVoteLayout.contentToOptionSpacing) { + VStack(spacing: 0) { contentSection(battle) + // 유연 간격: 콘텐츠는 위(상단 spacer)에 고정, 옵션은 아래로 당겨 CTA 위 40 유지. + Spacer(minLength: PreVoteLayout.contentToOptionSpacing) optionSection(battle) } .padding(.horizontal, PreVoteLayout.contentHorizontalPadding) .padding(.top, PreVoteLayout.contentTopPadding) .padding(.bottom, PreVoteLayout.contentBottomSpacing) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background( LinearGradient( stops: [ diff --git a/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift b/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift index 44549c27..accdaf3c 100644 --- a/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift +++ b/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift @@ -12,16 +12,16 @@ import DesignSystem /// 홈 화면 최상단 GNB 위 헤더 (PicKé 로고 + 알림 아이콘). struct HifiHeaderView: View { let onNotificationTapped: () -> Void - + var body: some View { HStack { Image(asset: .appLogo) .resizable() .scaledToFit() .frame(width: 62, height: 39) - + Spacer() - + Button(action: onNotificationTapped) { Image(asset: .bell) .resizable() @@ -33,11 +33,5 @@ struct HifiHeaderView: View { .padding(.vertical, 8) .frame(height: 56) .background(Color.beige50) - .overlay(alignment: .bottom) { - Rectangle() - .fill(.beige600) - .frame(height: 1) - } } } - diff --git a/Projects/Presentation/Hifi/Sources/View/HifiView.swift b/Projects/Presentation/Hifi/Sources/View/HifiView.swift index 2c68fbbf..f1b3ce87 100644 --- a/Projects/Presentation/Hifi/Sources/View/HifiView.swift +++ b/Projects/Presentation/Hifi/Sources/View/HifiView.swift @@ -45,7 +45,6 @@ public struct HifiView: View { .contentShape(Rectangle()) .simultaneousGesture(categorySwipe) } - .background(Color.beige200.ignoresSafeArea()) .navigationBarHidden(true) .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } @@ -120,11 +119,9 @@ private extension HifiView { .padding(.horizontal, 16) } .scrollIndicators(.hidden) - .background(.beige200) + .background(.white) .overlay(alignment: .bottom) { - Rectangle() - .fill(.beige600) - .frame(height: 1.5) + Rectangle().fill(.beige600).frame(height: 1.5) } } @@ -132,9 +129,10 @@ private extension HifiView { func categoryTab(_ category: ExploreCategory) -> some View { let isSelected = store.selectedCategory == category Button { send(.categoryTapped(category)) } label: { + // picke.pen `뷰전환`: 탭 폭 50 고정, 선택 시 하단 4px primary500 풀폭 밑줄. Text(category.title) .pretendardFont(family: isSelected ? .Medium : .Regular, size: 14) - .foregroundStyle(isSelected ? Color.primary500 : Color.neutral300) + .foregroundStyle(isSelected ? Color.primary500 : Color.gray300) .frame(width: 50) .padding(.vertical, 8) .overlay(alignment: .bottom) { @@ -160,7 +158,14 @@ private extension HifiView { Button { send(.sortTapped(sort)) } label: { Text(sort.title) .pretendardFont(family: isSelected ? .SemiBold : .Medium, size: 12) - .foregroundStyle(isSelected ? Color.neutral800 : Color.neutral300) + .foregroundStyle(isSelected ? Color.beige50 : Color.primary500) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? .primary500 : .bgDefault, in: RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(.primary500, lineWidth: 1) + ) } .buttonStyle(.plain) } @@ -168,7 +173,7 @@ private extension HifiView { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(.beige200) + .background(.white) } } @@ -196,16 +201,18 @@ private extension HifiView { .foregroundStyle(.neutral500) .kerning(-0.35) .lineSpacing(14 * 0.28) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) } Text(item.summary) .pretendardFont(family: .Regular, size: 13) .foregroundStyle(.neutral400) .lineSpacing(13 * 0.4) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 2) } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift index 6c7edfc9..bd0034e1 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift @@ -18,34 +18,36 @@ struct BestBattleCardView: View { HStack(alignment: .top, spacing: 16) { Text("\(battle.rank)") .pretendardFont(family: .Bold, size: 28) - .foregroundStyle(.primary500) + .foregroundStyle(battle.rank == 1 ? .primary500 : .neutral300) .frame(width: 28, alignment: .leading) - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Text(battle.pair) .pretendardFont(family: .SemiBold, size: 11) .foregroundStyle(.primary500) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + Text(battle.title) .pretendardFont(family: .SemiBold, size: 14) .foregroundStyle(.neutral900) .lineLimit(2) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 8) { ForEach(battle.tags) { tag in Text("#\(tag.name)") .pretendardFont(family: .Medium, size: 11) .foregroundStyle(.neutral300) } + Spacer(minLength: 8) MetaLabelView(systemImage: "clock", text: "\(battle.durationMinutes)분") MetaLabelView(systemImage: "eye", text: "\(battle.viewCount.formatted())") } } - Spacer(minLength: 0) } .padding(.vertical, 16) - .padding(.horizontal, 12) - .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) - .overlay( - RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) - ) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift index 2dc7e939..92cb0de9 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift @@ -124,14 +124,10 @@ struct HeroCardView: View { Text(hero.optionA) .pretendardFont(family: .SemiBold, size: 14) .foregroundStyle(.beige100) - ZStack { - Circle() - .stroke(.secondary50.opacity(0.2), lineWidth: 2) - .frame(width: 32, height: 32) - Text("VS") - .pretendardFont(family: .SemiBold, size: 11) - .foregroundStyle(.secondary50) - } + Image(asset: .vs) + .resizable() + .scaledToFit() + .frame(width: 18, height: 32) Text(hero.optionB) .pretendardFont(family: .SemiBold, size: 14) .foregroundStyle(.beige100) @@ -150,6 +146,7 @@ struct HeroCardView: View { Text(hero.title) .pretendardFont(family: .SemiBold, size: 16) .foregroundStyle(.beige100) + .padding(.bottom, 4) Text(hero.summary) .pretendardFont(family: .Medium, size: 12) .foregroundStyle(.neutral200) diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift index 3220d345..39c19798 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift @@ -153,11 +153,9 @@ extension NewBattleCardView { @ViewBuilder private var vsBadge: some View { - Text("VS") - .pretendardFont(family: .Bold, size: 8) - .foregroundStyle(.neutral800) - .frame(width: 24, height: 24) - .background(.secondary200, in: Circle()) - .overlay(Circle().stroke(.beige50, lineWidth: 1.5)) + Image(asset: .vs) + .resizable() + .scaledToFit() + .frame(width: 18, height: 32) } } diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 366fa647..1a7c9ddd 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -100,11 +100,16 @@ extension HomeView { HomeSectionHeader(title: "Best 배틀") { send(.seeMoreTapped(.bestBattles)) } - VStack(spacing: 12) { - ForEach(store.bestBattles) { battle in + VStack(spacing: 0) { + ForEach(Array(store.bestBattles.enumerated()), id: \.element.id) { index, battle in BestBattleCardView(battle: battle) .contentShape(Rectangle()) .onTapGesture { send(.bestBattleTapped(battle)) } + if index < store.bestBattles.count - 1 { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } } } .padding(.horizontal, 16) diff --git a/Projects/Presentation/MainTab/Project.swift b/Projects/Presentation/MainTab/Project.swift index 5efc2acf..4010a39d 100644 --- a/Projects/Presentation/MainTab/Project.swift +++ b/Projects/Presentation/MainTab/Project.swift @@ -15,7 +15,8 @@ let project = Project.makeAppModule( .Domain(implements: .UseCase), .Shared(implements: .DesignSystem), .Presentation(implements: .Home), - .Presentation(implements: .Hifi) + .Presentation(implements: .Hifi), + .Presentation(implements: .Battle) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift index 97a3ee5e..a1f326eb 100644 --- a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -10,6 +10,7 @@ import Foundation import ComposableArchitecture import TCAFlow +import Battle import DesignSystem import Hifi import Home @@ -50,9 +51,11 @@ public struct MainTabCoordinator { @ObservableState public struct State: Equatable { public var selectedTab: Int + /// 직전 탭 — 탐색/빠른배틀 상단 백탭 시 이 탭으로 복귀. + public var previousTab: Int = Tab.home.rawValue public var homeState: HomeCoordinator.State public var exploreState: HifiCoordinator.State - public var quickBattleState: HomeCoordinator.State + public var quickBattleState: BattleCoordinator.State public var myPageState: HomeCoordinator.State public init(selectedTab: Int = Tab.home.rawValue) { @@ -70,7 +73,7 @@ public struct MainTabCoordinator { case tabReselected(Int) case home(HomeCoordinator.Action) case explore(HifiCoordinator.Action) - case quickBattle(HomeCoordinator.Action) + case quickBattle(BattleCoordinator.Action) case myPage(HomeCoordinator.Action) } @@ -82,7 +85,7 @@ public struct MainTabCoordinator { HifiCoordinator() } Scope(state: \.quickBattleState, action: \.quickBattle) { - HomeCoordinator() + BattleCoordinator() } Scope(state: \.myPageState, action: \.myPage) { HomeCoordinator() @@ -91,6 +94,7 @@ public struct MainTabCoordinator { Reduce { state, action in switch action { case let .selectTab(tab): + if tab != state.selectedTab { state.previousTab = state.selectedTab } state.selectedTab = tab return .none @@ -105,6 +109,11 @@ public struct MainTabCoordinator { } return .none + // 빠른배틀(오늘의 배틀) 상단 백탭 → 직전 탭으로 복귀 + case .quickBattle(.router(.routeAction(_, action: .battle(.delegate(.backToHome))))): + state.selectedTab = state.previousTab + return .none + case .home, .explore, .quickBattle, .myPage: return .none } diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift index 6cfa1168..f6f8b0aa 100644 --- a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -8,6 +8,7 @@ import SwiftUI import UIKit +import Battle import DesignSystem import Hifi import Home @@ -38,13 +39,15 @@ public struct MainTabView: View { ) { tabContent(for: $0) } + // 기본/선택 색은 UITabBarAppearance 와 tint 가 제어. + .tint(.neutral900) } } extension MainTabView { private static func configureTabBarAppearance() { let selectedColor = UIColor.neutral900 - let normalColor = UIColor.neutral900.withAlphaComponent(0.4) + let normalColor = UIColor.gray200 let backgroundColor = UIColor.bgDefault let borderColor = UIColor.borderDefault.withAlphaComponent(0.4) let font = UIFont.pretendardFontFamily(family: .Medium, size: 12) @@ -101,7 +104,7 @@ extension MainTabView { .iconAsset(isSelected: isSelected) ?? .none Image(asset: asset) - .renderingMode(.original) + .renderingMode(.template) .resizable() .scaledToFit() .frame(width: 24, height: 24) @@ -121,7 +124,7 @@ extension MainTabView { ) case .quickBattle: - HomeCoordinatorView( + BattleCoordinatorView( store: store.scope(state: \.quickBattleState, action: \.quickBattle) ) diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift index 6ac0463d..0560f3ac 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift +++ b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift @@ -100,11 +100,11 @@ extension SplashFeature { let hasStoredCredential = hasStoredCredential return .run { send in try await clock.sleep(for: .seconds(1.2)) -// if hasStoredCredential { -// await send(.delegate(.presentMainTab)) -// } else { -// await send(.delegate(.presentAuth)) -// } + if hasStoredCredential { + await send(.delegate(.presentMainTab)) + } else { + await send(.delegate(.presentAuth)) + } } } diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json index 49558787..17dda746 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지.png", + "filename" : "온보딩 1.svg", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" new file mode 100644 index 00000000..32f7c27c --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\230\250\353\263\264\353\224\251 1.svg" @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.png" deleted file mode 100644 index 4b99e808..00000000 Binary files "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.png" and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json index a1a7abc4..acd27f1a 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지.svg", + "filename" : "온보딩 2.svg", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" new file mode 100644 index 00000000..8c433b66 --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\230\250\353\263\264\353\224\251 2.svg" @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" deleted file mode 100644 index baa61afc..00000000 --- "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json index 31857c84..b3fc69ea 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지 1.png", + "filename" : "온보딩 3.svg", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" new file mode 100644 index 00000000..e8dc7f97 --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\230\250\353\263\264\353\224\251 3.svg" @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200 1.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200 1.png" deleted file mode 100644 index 4d1a9926..00000000 Binary files "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200 1.png" and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json index 49558787..e5d61ce3 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "이미지.png", + "filename" : "온보딩 4.svg", "idiom" : "universal" } ], diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" new file mode 100644 index 00000000..d71c48eb --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\230\250\353\263\264\353\224\251 4.svg" @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.png" deleted file mode 100644 index fa6e43ac..00000000 Binary files "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.png" and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/Contents.json new file mode 100644 index 00000000..88bb0aec --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "versus_home.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/versus_home.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/versus_home.svg new file mode 100644 index 00000000..f99a6516 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/versus_home.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift index d9337f60..4842c888 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift @@ -19,12 +19,13 @@ public extension UIColor { scanner.scanHexInt64(&rgb) let r = Double((rgb >> 16) & 0xFF) / 255.0 - let g = Double((rgb >> 8) & 0xFF) / 255.0 - let b = Double((rgb >> 0) & 0xFF) / 255.0 + let g = Double((rgb >> 8) & 0xFF) / 255.0 + let b = Double((rgb >> 0) & 0xFF) / 255.0 self.init(red: r, green: g, blue: b, alpha: alpha) } static var neutral900: UIColor { .init(hex: "131212") } + static var gray200: UIColor { .init(hex: "B0AFAE") } static var bgDefault: UIColor { .init(hex: "FAFAF9") } static var borderDefault: UIColor { .init(hex: "EFEAE0") } } diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 715e900f..6869683c 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -25,6 +25,7 @@ public enum ImageAsset: String { case onboarding2 case onboarding3 case onboarding4 + case vs // MARK: - GNB 탭 아이콘 (비선택 / 선택) diff --git a/Projects/Shared/Utill/Sources/Base.swift b/Projects/Shared/Utill/Sources/Base.swift deleted file mode 100644 index 6297cc4c..00000000 --- a/Projects/Shared/Utill/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2025-09-04 -// Copyright © 2025 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/Shared/Utill/Sources/Extension/Int+.swift b/Projects/Shared/Utill/Sources/Extension/Int+.swift new file mode 100644 index 00000000..748d27fe --- /dev/null +++ b/Projects/Shared/Utill/Sources/Extension/Int+.swift @@ -0,0 +1,32 @@ +// +// Int+DecimalFormat.swift +// Utill +// +// 숫자 표시용 공통 포맷 (좋아요/조회수 등). Chat / Home / Auth / Hifi 등에서 공용 사용. +// + +import Foundation + +public extension Int { + /// 천 단위 구분 콤마가 적용된 문자열. (예: 1340 → "1,340") + var decimalFormatted: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: self)) ?? "\(self)" + } + + /// 재생시간 초 → "X분 Y초". + var durationText: String { + let minutes = self / 60 + let seconds = self % 60 + if minutes > 0, seconds > 0 { return "\(minutes)분 \(seconds)초" } + if minutes > 0 { return "\(minutes)분" } + return "\(seconds)초" + } + + /// 재생시간 초 → 반올림한 분 단위 텍스트. 1분 미만은 "1분". + var roundedMinuteText: String { + let minutes = max(1, Int((Double(self) / 60).rounded())) + return "\(minutes)분" + } +} diff --git a/Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift b/Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift deleted file mode 100644 index e3546704..00000000 --- a/Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Int+DecimalFormat.swift -// Utill -// -// 숫자 표시용 공통 포맷 (좋아요/조회수 등). Chat / Home / Auth / Hifi 등에서 공용 사용. -// - -import Foundation - -public extension Int { - /// 천 단위 구분 콤마가 적용된 문자열. (예: 1340 → "1,340") - var decimalFormatted: String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter.string(from: NSNumber(value: self)) ?? "\(self)" - } -} diff --git a/Projects/Shared/Utill/Sources/Extension/String+.swift b/Projects/Shared/Utill/Sources/Extension/String+.swift new file mode 100644 index 00000000..6f908c04 --- /dev/null +++ b/Projects/Shared/Utill/Sources/Extension/String+.swift @@ -0,0 +1,30 @@ +// +// String+Sentence.swift +// Utill +// +// 문자열을 문장 단위로 분할하는 공통 유틸 (채팅 대사 한 문장씩 노출 등). +// + +import Foundation + +public extension String { + /// 문장 끝(`. ? !` / 줄바꿈) 기준으로 분할한다. 구분자는 포함, 공백뿐인 조각은 제외. + /// 분할 결과가 없으면 원본 1개를 그대로 반환. + func splitIntoSentences() -> [String] { + var result: [String] = [] + var current = "" + for character in self { + current.append(character) + if character == "." || character == "?" || character == "!" || character == "\n" { + if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + result.append(current) + } + current = "" + } + } + if !current.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + result.append(current) + } + return result.isEmpty ? [self] : result + } +} diff --git a/Projects/Shared/Utill/Sources/Extension/UUID+.swift b/Projects/Shared/Utill/Sources/Extension/UUID+.swift new file mode 100644 index 00000000..cc70dbfe --- /dev/null +++ b/Projects/Shared/Utill/Sources/Extension/UUID+.swift @@ -0,0 +1,17 @@ +// +// UUID+Deterministic.swift +// Utill +// +// 정수로부터 항상 동일한 UUID 를 생성하는 공통 유틸 (ForEach 안정 id 등). +// + +import Foundation + +public extension UUID { + /// 두 정수로부터 결정적(deterministic) UUID 생성. 같은 입력 → 항상 같은 UUID. + /// 마지막 12 hex 를 `high`(8) + `low`(4) 로 채운다. + static func deterministic(_ high: Int, _ low: Int = 0) -> UUID { + let hex = String(format: "%08X%04X", high & 0xFFFF_FFFF, low & 0xFFFF) + return UUID(uuidString: "00000000-0000-0000-0000-\(hex)") ?? UUID() + } +}