diff --git a/.swiftformat b/.swiftformat index 82430650..8fabc057 100644 --- a/.swiftformat +++ b/.swiftformat @@ -2,3 +2,4 @@ --swiftversion 6.0 --maxwidth 120 --disable redundantReturn +--disable redundantViewBuilder diff --git a/AGENTS.md b/AGENTS.md index 2f39d747..2d1d51aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,57 +148,39 @@ public var body: some View { - 한 메서드 안에서 다시 큰 블록이 생기면 더 작게 쪼개기 (재귀 적용) - 공통 컴포넌트는 별도 파일 (`Components/*.swift`) 로 추출 -#### 🧱 `@ViewBuilder` + `private func` — 모든 sub-view 는 함수 형태 (필수) +#### 🧱 sub-view 형태 — 자식 2개 이상 stack 은 `@ViewBuilder func`, 단일 뷰는 `var` (필수) -분리한 sub-view 는 **자식 개수 / 분기 유무와 상관없이 무조건 `@ViewBuilder` + `private func` 형태** 로 통일한다. +분리한 sub-view 는 **자식 개수로 형태를 정한다**. -`private var ...: some View` 형태는 **금지** — 모든 sub-view 는 호출부에서 일관되게 `()` 호출이 보이도록 함수 형태로만 작성한다. +- stack / grid 안에 자식이 **2개 이상** (`ForEach` 반복, `if` 분기 포함) → `@ViewBuilder private func name() -> some View` +- **단일 뷰** (Button 하나, Text 하나, 이미 만든 컴포넌트 하나) → `private var name: some View` +- 파라미터가 필요하면 자식 수와 상관없이 func (`func thumbnail(url:)`) ```swift -// ✅ 다중 자식 / 분기 / 반복 +// ✅ 자식 2개 이상 — @ViewBuilder func @ViewBuilder -private func hotBattlesSection() -> some View { - VStack(alignment: .leading, spacing: 12) { - HomeSectionHeader(title: "지금 뜨는 배틀") { send(.seeMoreTapped(.hotBattles)) } - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { - ForEach(store.hotBattles) { HotBattleCardView(battle: $0) } - } - } +private func titleSection() -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("어떤 주제로\n이야기 나눌까요?") + Text("관심 있는 주제나 수업 조건을 선택해 주세요.") } } -@ViewBuilder -private func thumbnail(url: URL?) -> some View { - if let url { - KFImage(url).resizable().scaledToFill() - } else { - Color.neutral200 +// ✅ 단일 뷰 — var +private var searchButton: some View { + Button { + send(.searchTapped) + } label: { + Text("조건에 맞는 콘텐츠 보기") } + .ctaButtonStyle(.primary, size: .large, height: 52) } -// ✅ 단일 뷰여도 함수 형태로 -@ViewBuilder -private func primaryButton() -> some View { - CustomButton( - action: { send(.primaryButtonTapped) }, - title: "사전 투표하기", - config: CustomButtonConfig.primary(.large, height: 52), - isEnable: store.isPrimaryButtonEnabled - ) -} - -// ❌ 금지 — sub-view 를 var 로 선언 -private var primaryButton: some View { ... } -private var section: some View { VStack { ... } } +// ❌ 금지 — 단일 뷰를 func 로, 다중 자식을 var 로 +private func searchButton() -> some View { Button { ... } } +private var titleSection: some View { VStack { Text(...); Text(...) } } ``` -규칙: -- **모든 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-파라미터 이하에서만 허용. diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index df0657ac..a5bafcef 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -16,6 +16,7 @@ public enum FeatureModule: String, CaseIterable { case hifi = "Hifi" case web = "Web" case battle = "Battle" + case classroom = "Class" case profile = "Profile" case notification = "Notification" case ad = "Ad" @@ -64,6 +65,7 @@ public enum DomainModule: String, CaseIterable { case attendance = "AttendanceDomain" case auth = "AuthDomain" case battle = "BattleDomain" + case classroom = "ClassDomain" case comment = "CommentDomain" case ad = "AdDomain" case home = "HomeDomain" diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index b5676182..d924f153 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -9,7 +9,7 @@ import Foundation import ProjectDescription public extension String { - static func appVersion(version: String = "1.1.0") -> String { + static func appVersion(version: String = "1.1.2") -> String { return version } @@ -17,7 +17,7 @@ public extension String { return Project.Environment.bundlePrefix } - static func appBuildVersion(buildVersion: String = "2609141641") -> String { + static func appBuildVersion(buildVersion: String = "2610012046") -> String { return buildVersion } diff --git a/Projects/App/Sources/Navigation/Auth/AppAuthCoordinator.swift b/Projects/App/Sources/Navigation/Auth/AuthCoordinator.swift similarity index 83% rename from Projects/App/Sources/Navigation/Auth/AppAuthCoordinator.swift rename to Projects/App/Sources/Navigation/Auth/AuthCoordinator.swift index de517976..0b14f353 100644 --- a/Projects/App/Sources/Navigation/Auth/AppAuthCoordinator.swift +++ b/Projects/App/Sources/Navigation/Auth/AuthCoordinator.swift @@ -1,5 +1,5 @@ // -// AppAuthCoordinator.swift +// AuthCoordinator.swift // Picke // @@ -9,13 +9,13 @@ import ComposableArchitecture import FeatureAssembly import TCAFlow -@FlowCoordinator(screen: "AppAuthScreen", navigation: true) -public struct AppAuthCoordinator { +@FlowCoordinator(screen: "AuthScreen", navigation: true) +public struct AuthCoordinator { public init() {} @ObservableState public struct State: Equatable { - var routes: [Route] + var routes: [Route] public init() { routes = [.root(.login(.init()), embedInNavigationView: true)] @@ -24,7 +24,7 @@ public struct AppAuthCoordinator { @CasePathable public enum Action { - case router(IndexedRouterActionOf) + case router(IndexedRouterActionOf) case view(View) case navigation(NavigationAction) } @@ -56,10 +56,10 @@ public struct AppAuthCoordinator { } } -private extension AppAuthCoordinator { +private extension AuthCoordinator { func routerAction( state: inout State, - action: IndexedRouterActionOf + action: IndexedRouterActionOf ) -> Effect { switch action { case .routeAction(_, action: .login(.delegate(.presentOnboarding))): @@ -101,9 +101,9 @@ private extension AppAuthCoordinator { } // swiftformat:disable extensionAccessControl -extension AppAuthCoordinator { +extension AuthCoordinator { @Reducer - public enum AppAuthScreen { + public enum AuthScreen { case login(LoginFeature) case onboarding(OnBoardingFeature) case web(WebReducer) @@ -112,4 +112,4 @@ extension AppAuthCoordinator { // swiftformat:enable extensionAccessControl -extension AppAuthCoordinator.AppAuthScreen.State: Equatable {} +extension AuthCoordinator.AuthScreen.State: Equatable {} diff --git a/Projects/App/Sources/Navigation/Auth/AppAuthCoordinatorView.swift b/Projects/App/Sources/Navigation/Auth/AuthCoordinatorView.swift similarity index 77% rename from Projects/App/Sources/Navigation/Auth/AppAuthCoordinatorView.swift rename to Projects/App/Sources/Navigation/Auth/AuthCoordinatorView.swift index 88df83b3..ec6fc0bc 100644 --- a/Projects/App/Sources/Navigation/Auth/AppAuthCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Auth/AuthCoordinatorView.swift @@ -1,5 +1,5 @@ // -// AppAuthCoordinatorView.swift +// AuthCoordinatorView.swift // Picke // @@ -9,10 +9,10 @@ import ComposableArchitecture import FeatureAssembly import TCAFlow -public struct AppAuthCoordinatorView: View { - @Bindable private var store: StoreOf +public struct AuthCoordinatorView: View { + @Bindable private var store: StoreOf - public init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } diff --git a/Projects/App/Sources/Navigation/Battle/AppBattleCoordinator.swift b/Projects/App/Sources/Navigation/Battle/BattleCoordinator.swift similarity index 87% rename from Projects/App/Sources/Navigation/Battle/AppBattleCoordinator.swift rename to Projects/App/Sources/Navigation/Battle/BattleCoordinator.swift index 79218520..9094a10f 100644 --- a/Projects/App/Sources/Navigation/Battle/AppBattleCoordinator.swift +++ b/Projects/App/Sources/Navigation/Battle/BattleCoordinator.swift @@ -1,5 +1,5 @@ // -// AppBattleCoordinator.swift +// BattleCoordinator.swift // Picke // @@ -12,15 +12,15 @@ import PickeDesignKit import FeatureAssembly import TCAFlow -@FlowCoordinator(screen: "AppBattleScreen", navigation: true) -public struct AppBattleCoordinator { +@FlowCoordinator(screen: "BattleScreen", navigation: true) +public struct BattleCoordinator { public init() {} @Dependency(\.audioPlayer) private var audioPlayer @ObservableState public struct State: Equatable { - public var routes: [Route] + public var routes: [Route] public init() { routes = [.root(.battle(.init()), embedInNavigationView: true)] @@ -29,7 +29,7 @@ public struct AppBattleCoordinator { @CasePathable public enum Action { - case router(IndexedRouterActionOf) + case router(IndexedRouterActionOf) case view(View) case async(AsyncAction) case inner(InnerAction) @@ -61,10 +61,10 @@ public struct AppBattleCoordinator { } } -private extension AppBattleCoordinator { +private extension BattleCoordinator { func routerAction( state: inout State, - action: IndexedRouterActionOf + action: IndexedRouterActionOf ) -> Effect { switch action { case let .routeAction(_, action: .battle(.delegate(.openBattle(battleId)))): @@ -127,9 +127,9 @@ private extension AppBattleCoordinator { } // swiftformat:disable extensionAccessControl -extension AppBattleCoordinator { +extension BattleCoordinator { @Reducer - public enum AppBattleScreen { + public enum BattleScreen { case battle(BattleFeature) case chatRoom(ChatRoomFeature) case chat(ChatCoordinator) @@ -138,4 +138,4 @@ extension AppBattleCoordinator { // swiftformat:enable extensionAccessControl -extension AppBattleCoordinator.AppBattleScreen.State: Equatable {} +extension BattleCoordinator.BattleScreen.State: Equatable {} diff --git a/Projects/App/Sources/Navigation/Battle/AppBattleCoordinatorView.swift b/Projects/App/Sources/Navigation/Battle/BattleCoordinatorView.swift similarity index 75% rename from Projects/App/Sources/Navigation/Battle/AppBattleCoordinatorView.swift rename to Projects/App/Sources/Navigation/Battle/BattleCoordinatorView.swift index 0268a21d..2d289c94 100644 --- a/Projects/App/Sources/Navigation/Battle/AppBattleCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Battle/BattleCoordinatorView.swift @@ -1,5 +1,5 @@ // -// AppBattleCoordinatorView.swift +// BattleCoordinatorView.swift // Picke // @@ -9,10 +9,10 @@ import ComposableArchitecture import FeatureAssembly import TCAFlow -public struct AppBattleCoordinatorView: View { - @Bindable private var store: StoreOf +public struct BattleCoordinatorView: View { + @Bindable private var store: StoreOf - public init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift new file mode 100644 index 00000000..14a4b7bb --- /dev/null +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift @@ -0,0 +1,239 @@ +// +// ClassCoordinator.swift +// Picke +// + +import ComposableArchitecture +import DomainAssembly +import FeatureAssembly +import Foundation +import TCAFlow + +@FlowCoordinator(screen: "ClassScreen", navigation: true) +public struct ClassCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init() { + routes = [.root(.intro(.init()), embedInNavigationView: true)] + } + + /// 내 클래스와 클래스 상세에서만 탭바를 보여준다. + /// 클래스 상세의 모달이 열려 있거나 멤버·리포트 화면이면 숨긴다. + public var showsTabBar: Bool { + switch routes.last?.screen { + case let .detail(detail)?: + return detail.modal == nil + case .myClasses?: + return true + case .ownerDashboard?: + return true + default: + return false + } + } + } + + @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 + } + } +} + +private extension ClassCoordinator { + func routerAction( + state: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + case let .routeAction(_, action: .intro(.delegate(.joined(room)))): + state.routes.push(.detail(.init(room: room))) + return .none + + case .routeAction(_, action: .intro(.delegate(.myClasses))): + state.routes.push(.myClasses(.init())) + return .none + + case .routeAction(_, action: .intro(.delegate(.create))): + state.routes.push(.topic(.init())) + return .none + + case .routeAction(_, action: .topic(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case let .routeAction(_, action: .topic(.delegate(.search(filter)))): + state.routes.push(.recommend(.init(filter: filter))) + return .none + + case .routeAction(_, action: .recommend(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case let .routeAction(_, action: .recommend(.delegate(.select(battle)))): + state.routes.push(.setting(.init(battle: battle))) + return .none + + case .routeAction(_, action: .setting(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case let .routeAction(_, action: .setting(.delegate(.created(room)))): + state.routes.push(.share(.init(room: room))) + return .none + + case .routeAction(_, action: .share(.delegate(.dismiss))): + return .send(.view(.backToRootAction)) + + case let .routeAction(_, action: .share(.delegate(.enterClass(room)))): + state.routes.goBackToRoot() + state.routes.push(.detail(.init(room: room))) + return .none + + case .routeAction(_, action: .join(.delegate(.dismiss))), + .routeAction(_, action: .myClasses(.delegate(.dismiss))), + .routeAction(_, action: .detail(.delegate(.dismiss))), + .routeAction(_, action: .members(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case let .routeAction(_, action: .join(.delegate(.joined(room)))), + let .routeAction(_, action: .myClasses(.delegate(.openRoom(room)))): + state.routes.push(.detail(.init(room: room))) + return .none + + case let .routeAction(_, action: .detail(.delegate(.openMembers(room)))): + state.routes.push(.members(.init(room: room))) + return .none + + case let .routeAction(_, action: .detail(.delegate(.openBattle(battle)))): + state.routes.push(.chat(.init(route: .preVote(battleId: battle.id)))) + return .none + + case let .routeAction(_, action: .detail(.delegate(.openReport(room)))): + if room.role == .owner { + state.routes.push(.ownerDashboard(.init(room: room))) + } else { + state.routes.push(.report(.init(room: room))) + } + return .none + + case .routeAction(_, action: .report(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case .routeAction(_, action: .ownerDashboard(.delegate(.dismiss))), + .routeAction(_, action: .ownerMemberDetail(.delegate(.dismiss))), + .routeAction(_, action: .ownerReplyDetail(.delegate(.dismiss))), + .routeAction(_, action: .ownerFeedback(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case let .routeAction(_, action: .ownerDashboard(.delegate(.openMemberDetail(member)))): + guard case let .ownerDashboard(dashboard)? = state.routes.last?.screen else { return .none } + state.routes.push(.ownerMemberDetail(.init(room: dashboard.room, member: member))) + return .none + + case let .routeAction(_, action: .ownerDashboard(.delegate(.openReplyDetail(opinion)))): + guard case let .ownerDashboard(dashboard)? = state.routes.last?.screen else { return .none } + state.routes.push(.ownerReplyDetail(.init(room: dashboard.room, opinion: opinion))) + return .none + + case let .routeAction(_, action: .ownerDashboard(.delegate(.openFeedback(member)))): + guard case let .ownerDashboard(dashboard)? = state.routes.last?.screen else { return .none } + state.routes.push(.ownerFeedback(.init(room: dashboard.room, memberName: member.name))) + return .none + + case let .routeAction(_, action: .ownerMemberDetail(.delegate(.commentSelected(opinion)))): + guard case let .ownerMemberDetail(member)? = state.routes.last?.screen else { return .none } + state.routes.push(.ownerReplyDetail(.init(room: member.room, opinion: opinion))) + return .none + + case .routeAction(_, action: .ownerMemberDetail(.delegate(.feedbackSelected))): + guard case let .ownerMemberDetail(member)? = state.routes.last?.screen else { return .none } + state.routes.push(.ownerFeedback(.init(room: member.room, memberName: member.member.name))) + return .none + + case .routeAction(_, action: .ownerFeedback(.delegate(.submitted))): + return .send(.view(.backAction)) + + case .routeAction(_, action: .chat(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case .routeAction(_, action: .chat(.delegate(.popToRoot))): + return .send(.view(.backToRootAction)) + + case .routeAction(_, action: .detail(.delegate(.deleted))): + return .send(.view(.backToRootAction)) + + default: + return .none + } + } + + 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 ClassCoordinator { + @Reducer + public enum ClassScreen { + case intro(ClassIntroFeature) + case topic(ClassTopicFeature) + case recommend(ClassRecommendFeature) + case setting(ClassSettingFeature) + case share(ClassShareFeature) + case join(ClassJoinFeature) + case myClasses(MyClassFeature) + case detail(ClassDetailFeature) + case members(ClassMemberFeature) + case report(ClassReportFeature) + case ownerDashboard(ClassOwnerDashboardFeature) + case ownerMemberDetail(ClassMemberDetailFeature) + case ownerReplyDetail(ClassReplyDetailFeature) + case ownerFeedback(ClassFeedbackComposeFeature) + case chat(ChatCoordinator) + } +} + +// swiftformat:enable extensionAccessControl + +extension ClassCoordinator.ClassScreen.State: Equatable {} diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift new file mode 100644 index 00000000..78f6c185 --- /dev/null +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift @@ -0,0 +1,69 @@ +// +// ClassCoordinatorView.swift +// Picke +// + +import ComposableArchitecture +import FeatureAssembly +import SwiftUI +import TCAFlow + +public struct ClassCoordinatorView: 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 .intro(introStore): + ClassIntroView(store: introStore) + .toolbar(store.showsTabBar ? .visible : .hidden, for: .tabBar) + + case let .topic(topicStore): + ClassTopicView(store: topicStore) + + case let .recommend(recommendStore): + ClassRecommendView(store: recommendStore) + + case let .setting(settingStore): + ClassSettingView(store: settingStore) + + case let .share(shareStore): + ClassShareView(store: shareStore) + + case let .join(joinStore): + ClassJoinView(store: joinStore) + + case let .myClasses(myClassesStore): + MyClassView(store: myClassesStore) + + case let .detail(detailStore): + ClassDetailView(store: detailStore) + + case let .members(membersStore): + ClassMemberView(store: membersStore) + + case let .report(reportStore): + ClassReportView(store: reportStore) + + case let .ownerDashboard(dashboardStore): + ClassOwnerDashboardView(store: dashboardStore) + + case let .ownerMemberDetail(memberStore): + ClassMemberDetailView(store: memberStore) + + case let .ownerReplyDetail(replyStore): + ClassReplyDetailView(store: replyStore) + + case let .ownerFeedback(feedbackStore): + ClassFeedbackComposeView(store: feedbackStore) + + case let .chat(chatStore): + ChatCoordinatorView(store: chatStore) + } + } + } +} diff --git a/Projects/App/Sources/Navigation/Hifi/AppHifiCoordinator.swift b/Projects/App/Sources/Navigation/Hifi/HifiCoordinator.swift similarity index 86% rename from Projects/App/Sources/Navigation/Hifi/AppHifiCoordinator.swift rename to Projects/App/Sources/Navigation/Hifi/HifiCoordinator.swift index 85ac3a53..af7d1567 100644 --- a/Projects/App/Sources/Navigation/Hifi/AppHifiCoordinator.swift +++ b/Projects/App/Sources/Navigation/Hifi/HifiCoordinator.swift @@ -1,5 +1,5 @@ // -// AppHifiCoordinator.swift +// HifiCoordinator.swift // Picke // @@ -10,15 +10,15 @@ import ComposableArchitecture import FeatureAssembly import TCAFlow -@FlowCoordinator(screen: "AppHifiScreen", navigation: true) -public struct AppHifiCoordinator { +@FlowCoordinator(screen: "HifiScreen", navigation: true) +public struct HifiCoordinator { public init() {} @Dependency(\.audioPlayer) private var audioPlayer @ObservableState public struct State: Equatable { - public var routes: [Route] + public var routes: [Route] public init() { routes = [.root(.hifi(.init()), embedInNavigationView: true)] @@ -27,7 +27,7 @@ public struct AppHifiCoordinator { @CasePathable public enum Action { - case router(IndexedRouterActionOf) + case router(IndexedRouterActionOf) case view(View) case async(AsyncAction) case inner(InnerAction) @@ -59,10 +59,10 @@ public struct AppHifiCoordinator { } } -private extension AppHifiCoordinator { +private extension HifiCoordinator { func routerAction( state: inout State, - action: IndexedRouterActionOf + action: IndexedRouterActionOf ) -> Effect { switch action { case let .routeAction(_, action: .hifi(.delegate(.openBattle(battleId)))): @@ -116,9 +116,9 @@ private extension AppHifiCoordinator { } // swiftformat:disable extensionAccessControl -extension AppHifiCoordinator { +extension HifiCoordinator { @Reducer - public enum AppHifiScreen { + public enum HifiScreen { case hifi(HifiFeature) case chat(ChatCoordinator) case notification(NotificationCoordinator) @@ -127,4 +127,4 @@ extension AppHifiCoordinator { // swiftformat:enable extensionAccessControl -extension AppHifiCoordinator.AppHifiScreen.State: Equatable {} +extension HifiCoordinator.HifiScreen.State: Equatable {} diff --git a/Projects/App/Sources/Navigation/Hifi/AppHifiCoordinatorView.swift b/Projects/App/Sources/Navigation/Hifi/HifiCoordinatorView.swift similarity index 77% rename from Projects/App/Sources/Navigation/Hifi/AppHifiCoordinatorView.swift rename to Projects/App/Sources/Navigation/Hifi/HifiCoordinatorView.swift index e6abcf9e..e6c19b88 100644 --- a/Projects/App/Sources/Navigation/Hifi/AppHifiCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Hifi/HifiCoordinatorView.swift @@ -1,5 +1,5 @@ // -// AppHifiCoordinatorView.swift +// HifiCoordinatorView.swift // Picke // @@ -9,10 +9,10 @@ import ComposableArchitecture import FeatureAssembly import TCAFlow -public struct AppHifiCoordinatorView: View { - @Bindable private var store: StoreOf +public struct HifiCoordinatorView: View { + @Bindable private var store: StoreOf - public init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } diff --git a/Projects/App/Sources/Navigation/Home/AppHomeCoordinator.swift b/Projects/App/Sources/Navigation/Home/HomeCoordinator.swift similarity index 89% rename from Projects/App/Sources/Navigation/Home/AppHomeCoordinator.swift rename to Projects/App/Sources/Navigation/Home/HomeCoordinator.swift index 0c869511..4ea4a512 100644 --- a/Projects/App/Sources/Navigation/Home/AppHomeCoordinator.swift +++ b/Projects/App/Sources/Navigation/Home/HomeCoordinator.swift @@ -1,5 +1,5 @@ // -// AppHomeCoordinator.swift +// HomeCoordinator.swift // Picke // @@ -12,15 +12,15 @@ import PickeDesignKit import FeatureAssembly import TCAFlow -@FlowCoordinator(screen: "AppHomeScreen", navigation: true) -public struct AppHomeCoordinator { +@FlowCoordinator(screen: "HomeScreen", navigation: true) +public struct HomeCoordinator { public init() {} @Dependency(\.audioPlayer) private var audioPlayer @ObservableState public struct State: Equatable { - public var routes: [Route] + public var routes: [Route] public init() { routes = [.root(.home(.init()), embedInNavigationView: true)] @@ -29,7 +29,7 @@ public struct AppHomeCoordinator { @CasePathable public enum Action { - case router(IndexedRouterActionOf) + case router(IndexedRouterActionOf) case view(View) case async(AsyncAction) case inner(InnerAction) @@ -63,10 +63,10 @@ public struct AppHomeCoordinator { } } -private extension AppHomeCoordinator { +private extension HomeCoordinator { func routerAction( state: inout State, - action: IndexedRouterActionOf + action: IndexedRouterActionOf ) -> Effect { switch action { case let .routeAction(_, action: .home(.delegate(.presentPreVote(battleId)))): @@ -140,9 +140,9 @@ private extension AppHomeCoordinator { } // swiftformat:disable extensionAccessControl -extension AppHomeCoordinator { +extension HomeCoordinator { @Reducer - public enum AppHomeScreen { + public enum HomeScreen { case home(HomeFeature) case chat(ChatCoordinator) case notification(NotificationCoordinator) @@ -151,4 +151,4 @@ extension AppHomeCoordinator { // swiftformat:enable extensionAccessControl -extension AppHomeCoordinator.AppHomeScreen.State: Equatable {} +extension HomeCoordinator.HomeScreen.State: Equatable {} diff --git a/Projects/App/Sources/Navigation/Home/AppHomeCoordinatorView.swift b/Projects/App/Sources/Navigation/Home/HomeCoordinatorView.swift similarity index 78% rename from Projects/App/Sources/Navigation/Home/AppHomeCoordinatorView.swift rename to Projects/App/Sources/Navigation/Home/HomeCoordinatorView.swift index c53e5ec5..24daee09 100644 --- a/Projects/App/Sources/Navigation/Home/AppHomeCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Home/HomeCoordinatorView.swift @@ -1,5 +1,5 @@ // -// AppHomeCoordinatorView.swift +// HomeCoordinatorView.swift // Picke // @@ -9,10 +9,10 @@ import ComposableArchitecture import FeatureAssembly import TCAFlow -public struct AppHomeCoordinatorView: View { - @Bindable private var store: StoreOf +public struct HomeCoordinatorView: View { + @Bindable private var store: StoreOf - public init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } diff --git a/Projects/App/Sources/Navigation/MainTab/AppMainTabView.swift b/Projects/App/Sources/Navigation/MainTab/AppMainTabView.swift deleted file mode 100644 index b0df67c9..00000000 --- a/Projects/App/Sources/Navigation/MainTab/AppMainTabView.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// AppMainTabView.swift -// Picke -// -// Created by Codex on 7/11/26. -// - -import SwiftUI -import UIKit - -import ComposableArchitecture -import PickeCoreUI -import PickeDesignKit -import FeatureAssembly -import TCAFlow - -public struct AppMainTabView: View { - @Bindable private var store: StoreOf - - public init(store: StoreOf) { - Self.configureTabBarAppearance() - self.store = store - } - - public var body: some View { - TCAFlowTabRouter( - selectedTab: $store.selectedTab.sending(\.selectTab), - tabs: AppMainTabCoordinator.Tab.allCases.map { - TabItem( - title: $0.title, - icon: $0.iconAsset(isSelected: false).rawValue, - tag: $0.rawValue - ) - }, - onReselect: { tab in - store.send(.tabReselected(tab)) - }, - tabItemLabel: { tab in - tabLabel(for: tab) - } - ) { - tabContent(for: $0) - } - .tint(.neutral900) - .toolbar(store.shouldHideTabBar ? .hidden : .automatic, for: .tabBar) - } -} - -private extension AppMainTabView { - static func configureTabBarAppearance() { - let selectedColor = UIColor.neutral900 - let normalColor = UIColor.gray200 - let backgroundColor = UIColor.bgDefault - let borderColor = UIColor.borderDefault.withAlphaComponent(0.4) - let font = UIFont.pretendardFontFamily(family: .Medium, size: 12) - - let appearance = UITabBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = backgroundColor - appearance.shadowColor = borderColor - appearance.selectionIndicatorImage = UIImage() - - configureItemAppearance( - appearance.stackedLayoutAppearance, - selectedColor, - normalColor, - font - ) - configureItemAppearance( - appearance.inlineLayoutAppearance, - selectedColor, - normalColor, - font - ) - configureItemAppearance( - appearance.compactInlineLayoutAppearance, - selectedColor, - normalColor, - font - ) - - UITabBar.appearance().standardAppearance = appearance - UITabBar.appearance().scrollEdgeAppearance = appearance - UITabBar.appearance().tintColor = selectedColor - UITabBar.appearance().unselectedItemTintColor = normalColor - } - - static func configureItemAppearance( - _ itemAppearance: UITabBarItemAppearance, - _ selectedColor: UIColor, - _ normalColor: UIColor, - _ font: UIFont - ) { - itemAppearance.normal.iconColor = normalColor - itemAppearance.normal.titleTextAttributes = [ - .font: font, - .foregroundColor: normalColor, - ] - - itemAppearance.selected.iconColor = selectedColor - itemAppearance.selected.titleTextAttributes = [ - .font: font, - .foregroundColor: selectedColor, - ] - } - - @ViewBuilder - func tabLabel(for tab: TabItem) -> some View { - Label { - Text(tab.title) - .pretendardFont(.labelSmall) - } icon: { - tabIcon(for: tab) - } - .accessibilityIdentifier("tab.\(tab.tag)") - } - - @ViewBuilder - func tabIcon(for tab: TabItem) -> some View { - let isSelected = store.selectedTab == tab.tag - let asset = AppMainTabCoordinator.Tab(rawValue: tab.tag)? - .iconAsset(isSelected: isSelected) ?? .none - - Image(asset: asset) - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - } - - // TabView 는 네 탭의 콘텐츠를 한꺼번에 만들기 때문에, LazyView 로 감싸 - // 각 탭이 처음 화면에 올라올 때까지 스토어 스코프와 뷰 생성을 미룬다. - @ViewBuilder - func tabContent(for tab: Int) -> some View { - switch AppMainTabCoordinator.Tab(rawValue: tab) { - case .home: - LazyView { - AppHomeCoordinatorView( - store: store.scope(state: \.homeState, action: \.home) - ) - } - - case .explore: - LazyView { - AppHifiCoordinatorView( - store: store.scope(state: \.exploreState, action: \.explore) - ) - } - - case .quickBattle: - LazyView { - AppBattleCoordinatorView( - store: store.scope(state: \.quickBattleState, action: \.quickBattle) - ) - } - - case .myPage: - LazyView { - AppProfileCoordinatorView( - store: store.scope(state: \.myPageState, action: \.myPage) - ) - } - - case .none: - EmptyView() - } - } -} - -#Preview { - AppMainTabView( - store: Store(initialState: AppMainTabCoordinator.State()) { - AppMainTabCoordinator() - } - ) -} diff --git a/Projects/App/Sources/Navigation/MainTab/AppMainTabCoordinator.swift b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift similarity index 75% rename from Projects/App/Sources/Navigation/MainTab/AppMainTabCoordinator.swift rename to Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift index 808c52d9..2d6dabf8 100644 --- a/Projects/App/Sources/Navigation/MainTab/AppMainTabCoordinator.swift +++ b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift @@ -1,5 +1,5 @@ // -// AppMainTabCoordinator.swift +// MainTabCoordinator.swift // Picke // // Created by Codex on 7/11/26. @@ -14,20 +14,22 @@ import FeatureAssembly import TCAFlow @Reducer -public struct AppMainTabCoordinator { +public struct MainTabCoordinator { public init() {} public enum Tab: Int, CaseIterable { - case home - case explore - case quickBattle - case myPage + case home = 0 + case explore = 1 + case quickBattle = 2 + case classroom = 4 + case myPage = 3 public var title: String { switch self { case .home: return "홈" case .explore: return "탐색" case .quickBattle: return "빠른 배틀" + case .classroom: return "클래스" case .myPage: return "마이" } } @@ -37,6 +39,7 @@ public struct AppMainTabCoordinator { case .home: return isSelected ? .tabHomeActive : .tabHome case .explore: return isSelected ? .tabExploreActive : .tabExplore case .quickBattle: return isSelected ? .tabQuickBattleActive : .tabQuickBattle + case .classroom: return isSelected ? .tabClassActive : .tabClass case .myPage: return isSelected ? .tabMyPageActive : .tabMyPage } } @@ -46,10 +49,11 @@ public struct AppMainTabCoordinator { public struct State: Equatable { public var selectedTab: Int public var previousTab: Int = Tab.home.rawValue - public var homeState: AppHomeCoordinator.State - public var exploreState: AppHifiCoordinator.State - public var quickBattleState: AppBattleCoordinator.State - public var myPageState: AppProfileCoordinator.State + public var homeState: HomeCoordinator.State + public var exploreState: HifiCoordinator.State + public var quickBattleState: BattleCoordinator.State + public var classState: ClassCoordinator.State + public var myPageState: ProfileCoordinator.State public var shouldHideTabBar: Bool { switch Tab(rawValue: selectedTab) { @@ -59,6 +63,8 @@ public struct AppMainTabCoordinator { return exploreState.routes.count > 1 case .quickBattle: return quickBattleState.routes.count > 1 + case .classroom: + return !classState.showsTabBar case .myPage: return myPageState.routes.count > 1 case .none: @@ -71,6 +77,7 @@ public struct AppMainTabCoordinator { homeState = .init() exploreState = .init() quickBattleState = .init() + classState = .init() myPageState = .init() } } @@ -79,10 +86,11 @@ public struct AppMainTabCoordinator { public enum Action { case selectTab(Int) case tabReselected(Int) - case home(AppHomeCoordinator.Action) - case explore(AppHifiCoordinator.Action) - case quickBattle(AppBattleCoordinator.Action) - case myPage(AppProfileCoordinator.Action) + case home(HomeCoordinator.Action) + case explore(HifiCoordinator.Action) + case quickBattle(BattleCoordinator.Action) + case classroom(ClassCoordinator.Action) + case myPage(ProfileCoordinator.Action) case delegate(DelegateAction) } @@ -94,16 +102,19 @@ public struct AppMainTabCoordinator { public var body: some ReducerOf { Scope(state: \.homeState, action: \.home) { - AppHomeCoordinator() + HomeCoordinator() } Scope(state: \.exploreState, action: \.explore) { - AppHifiCoordinator() + HifiCoordinator() } Scope(state: \.quickBattleState, action: \.quickBattle) { - AppBattleCoordinator() + BattleCoordinator() + } + Scope(state: \.classState, action: \.classroom) { + ClassCoordinator() } Scope(state: \.myPageState, action: \.myPage) { - AppProfileCoordinator() + ProfileCoordinator() } Reduce { state, action in @@ -128,6 +139,10 @@ public struct AppMainTabCoordinator { state.selectedTab = state.previousTab return .none + case .classroom(.router(.routeAction(_, action: .intro(.delegate(.backToHome))))): + state.selectedTab = state.previousTab + return .none + case .myPage(.router(.routeAction(_, action: .settings(.delegate(.sessionEnded))))): return .send(.delegate(.sessionEnded)) @@ -144,14 +159,14 @@ public struct AppMainTabCoordinator { case .delegate: return .none - case .home, .explore, .quickBattle, .myPage: + case .home, .explore, .quickBattle, .classroom, .myPage: return .none } } } } -private extension AppMainTabCoordinator { +private extension MainTabCoordinator { func trackTabSelection(_ tab: Int) { switch Tab(rawValue: tab) { case .home: @@ -160,6 +175,8 @@ private extension AppMainTabCoordinator { analyticsUseCase.track(.uiAction(action: .tabExplore, screen: .mainTab)) case .quickBattle: analyticsUseCase.track(.uiAction(action: .tabQuickBattle, screen: .mainTab)) + case .classroom: + analyticsUseCase.track(.uiAction(action: .tabClass, screen: .mainTab)) case .myPage: analyticsUseCase.track(.uiAction(action: .tabMypage, screen: .mainTab)) case .none: @@ -178,6 +195,8 @@ private extension AppMainTabCoordinator { state.exploreState.routes.goBackToRoot() case .quickBattle: state.quickBattleState.routes.goBackToRoot() + case .classroom: + state.classState.routes.goBackToRoot() case .myPage: state.myPageState.routes.goBackToRoot() case .none: diff --git a/Projects/App/Sources/Navigation/MainTab/MainTabView.swift b/Projects/App/Sources/Navigation/MainTab/MainTabView.swift new file mode 100644 index 00000000..c11e6259 --- /dev/null +++ b/Projects/App/Sources/Navigation/MainTab/MainTabView.swift @@ -0,0 +1,114 @@ +// +// MainTabView.swift +// Picke +// +// Created by Codex on 7/11/26. +// + +import ComposableArchitecture +import FeatureAssembly +import PickeCoreUI +import PickeDesignKit +import PickeSharedUI +import SwiftUI +import TCAFlow + +public struct MainTabView: View { + @Bindable private var store: StoreOf + + public init(store: StoreOf) { + PickeTabBarAppearance.configure() + self.store = store + } + + public var body: some View { + TCAFlowTabRouter( + selectedTab: $store.selectedTab.sending(\.selectTab), + tabs: MainTabCoordinator.Tab.allCases.map { + TabItem( + title: $0.title, + icon: $0.iconAsset(isSelected: false).rawValue, + tag: $0.rawValue + ) + }, + onReselect: { tab in + store.send(.tabReselected(tab)) + }, + tabItemLabel: { tab in + tabLabel(for: tab) + } + ) { + tabContent(for: $0) + } + .tint(.neutral900) + .toolbar(store.shouldHideTabBar ? .hidden : .visible, for: .tabBar) + } +} + +private extension MainTabView { + @ViewBuilder + func tabLabel(for tab: TabItem) -> some View { + let isSelected = store.selectedTab == tab.tag + let asset = MainTabCoordinator.Tab(rawValue: tab.tag)? + .iconAsset(isSelected: isSelected) ?? .none + + PickeTabItemLabel( + title: tab.title, + icon: asset, + tag: tab.tag + ) + } + + /// TabView 는 네 탭의 콘텐츠를 한꺼번에 만들기 때문에, LazyView 로 감싸 + /// 각 탭이 처음 화면에 올라올 때까지 스토어 스코프와 뷰 생성을 미룬다. + @ViewBuilder + func tabContent(for tab: Int) -> some View { + switch MainTabCoordinator.Tab(rawValue: tab) { + case .home: + LazyView { + HomeCoordinatorView( + store: store.scope(state: \.homeState, action: \.home) + ) + } + + case .explore: + LazyView { + HifiCoordinatorView( + store: store.scope(state: \.exploreState, action: \.explore) + ) + } + + case .quickBattle: + LazyView { + BattleCoordinatorView( + store: store.scope(state: \.quickBattleState, action: \.quickBattle) + ) + } + + case .classroom: + LazyView { + ClassCoordinatorView( + store: store.scope(state: \.classState, action: \.classroom) + ) + } + + case .myPage: + LazyView { + ProfileCoordinatorView( + store: store.scope(state: \.myPageState, action: \.myPage) + ) + } + + case .none: + EmptyView() + } + } +} + +#Preview { + MainTabView( + store: Store(initialState: MainTabCoordinator.State()) { + MainTabCoordinator() + } + ) +} diff --git a/Projects/App/Sources/Navigation/Profile/AppProfileCoordinator.swift b/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift similarity index 93% rename from Projects/App/Sources/Navigation/Profile/AppProfileCoordinator.swift rename to Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift index 4dafc6e1..cac37db0 100644 --- a/Projects/App/Sources/Navigation/Profile/AppProfileCoordinator.swift +++ b/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift @@ -1,5 +1,5 @@ // -// AppProfileCoordinator.swift +// ProfileCoordinator.swift // Picke // @@ -11,15 +11,15 @@ import DomainAssembly import FeatureAssembly import TCAFlow -@FlowCoordinator(screen: "AppProfileScreen", navigation: true) -public struct AppProfileCoordinator { +@FlowCoordinator(screen: "ProfileScreen", navigation: true) +public struct ProfileCoordinator { public init() {} @Dependency(\.audioPlayer) private var audioPlayer @ObservableState public struct State: Equatable { - public var routes: [Route] + public var routes: [Route] public init() { routes = [.root(.profile(.init()), embedInNavigationView: true)] @@ -28,7 +28,7 @@ public struct AppProfileCoordinator { @CasePathable public enum Action { - case router(IndexedRouterActionOf) + case router(IndexedRouterActionOf) case view(View) case async(AsyncAction) case inner(InnerAction) @@ -62,10 +62,10 @@ public struct AppProfileCoordinator { } } -private extension AppProfileCoordinator { +private extension ProfileCoordinator { func routerAction( state: inout State, - action: IndexedRouterActionOf + action: IndexedRouterActionOf ) -> Effect { switch action { case .routeAction(_, action: .profile(.delegate(.editProfile))): @@ -207,9 +207,9 @@ private extension AppProfileCoordinator { } // swiftformat:disable extensionAccessControl -extension AppProfileCoordinator { +extension ProfileCoordinator { @Reducer - public enum AppProfileScreen { + public enum ProfileScreen { case profile(ProfileFeature) case pointHistory(PointHistoryFeature) case settings(SettingsFeature) @@ -228,4 +228,4 @@ extension AppProfileCoordinator { // swiftformat:enable extensionAccessControl -extension AppProfileCoordinator.AppProfileScreen.State: Equatable {} +extension ProfileCoordinator.ProfileScreen.State: Equatable {} diff --git a/Projects/App/Sources/Navigation/Profile/AppProfileCoordinatorView.swift b/Projects/App/Sources/Navigation/Profile/ProfileCoordinatorView.swift similarity index 89% rename from Projects/App/Sources/Navigation/Profile/AppProfileCoordinatorView.swift rename to Projects/App/Sources/Navigation/Profile/ProfileCoordinatorView.swift index 5c9a37f6..a3ae939c 100644 --- a/Projects/App/Sources/Navigation/Profile/AppProfileCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Profile/ProfileCoordinatorView.swift @@ -1,5 +1,5 @@ // -// AppProfileCoordinatorView.swift +// ProfileCoordinatorView.swift // Picke // @@ -9,10 +9,10 @@ import ComposableArchitecture import FeatureAssembly import TCAFlow -public struct AppProfileCoordinatorView: View { - @Bindable private var store: StoreOf +public struct ProfileCoordinatorView: View { + @Bindable private var store: StoreOf - public init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index 4c84a299..c8548448 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -21,8 +21,8 @@ public struct AppReducer: Sendable { @ObservableState public enum State: Equatable { case splash(SplashFeature.State) - case auth(AppAuthCoordinator.State) - case mainTab(AppMainTabCoordinator.State) + case auth(AuthCoordinator.State) + case mainTab(MainTabCoordinator.State) public init() { self = .splash(SplashFeature.State()) @@ -79,8 +79,8 @@ public struct AppReducer: Sendable { @CasePathable public enum ScopeAction { case splash(SplashFeature.Action) - case auth(AppAuthCoordinator.Action) - case mainTab(AppMainTabCoordinator.Action) + case auth(AuthCoordinator.Action) + case mainTab(MainTabCoordinator.Action) } @Dependency(\.continuousClock) var clock @@ -144,10 +144,10 @@ public struct AppReducer: Sendable { SplashFeature() } .ifCaseLet(\.auth, action: \.scope.auth) { - AppAuthCoordinator() + AuthCoordinator() } .ifCaseLet(\.mainTab, action: \.scope.mainTab) { - AppMainTabCoordinator() + MainTabCoordinator() } } @@ -207,30 +207,30 @@ public struct AppReducer: Sendable { case let .battle(battleId): // 홈 탭 전환 후 HomeCoordinator(Chat 보유)가 배틀 상세 push. return .merge( - .send(.scope(.mainTab(.selectTab(AppMainTabCoordinator.Tab.home.rawValue)))), + .send(.scope(.mainTab(.selectTab(MainTabCoordinator.Tab.home.rawValue)))), .send(.scope(.mainTab(.home(.view(.openBattle(battleId: battleId)))))) ) case let .perspective(perspectiveId, commentId): // 홈 탭 전환 후 HomeCoordinator(Chat 보유)가 관점(답글) 화면 push. return .merge( - .send(.scope(.mainTab(.selectTab(AppMainTabCoordinator.Tab.home.rawValue)))), + .send(.scope(.mainTab(.selectTab(MainTabCoordinator.Tab.home.rawValue)))), .send(.scope(.mainTab(.home(.view(.openPerspective(perspectiveId: perspectiveId, commentId: commentId)))))) ) case .point: // 마이페이지 탭 전환 후 포인트 내역 push. return .merge( - .send(.scope(.mainTab(.selectTab(AppMainTabCoordinator.Tab.myPage.rawValue)))), + .send(.scope(.mainTab(.selectTab(MainTabCoordinator.Tab.myPage.rawValue)))), .send(.scope(.mainTab(.myPage(.view(.openPointHistory))))) ) case .terms: // 마이페이지 탭 전환 후 서비스 약관 웹뷰 push. return .merge( - .send(.scope(.mainTab(.selectTab(AppMainTabCoordinator.Tab.myPage.rawValue)))), + .send(.scope(.mainTab(.selectTab(MainTabCoordinator.Tab.myPage.rawValue)))), .send(.scope(.mainTab(.myPage(.view(.openTerms))))) ) case .quickBattle: // 빠른 배틀은 탭 자체가 목적지라 전환만 한다. - return .send(.scope(.mainTab(.selectTab(AppMainTabCoordinator.Tab.quickBattle.rawValue)))) + return .send(.scope(.mainTab(.selectTab(MainTabCoordinator.Tab.quickBattle.rawValue)))) } } } diff --git a/Projects/App/Sources/View/AppView.swift b/Projects/App/Sources/View/AppView.swift index f957788e..79731deb 100644 --- a/Projects/App/Sources/View/AppView.swift +++ b/Projects/App/Sources/View/AppView.swift @@ -31,7 +31,7 @@ struct AppView: View { case .auth: if let store = store.scope(state: \.auth, action: \.scope.auth) { - AppAuthCoordinatorView(store: store) + AuthCoordinatorView(store: store) .transition(.asymmetric( insertion: .move(edge: .trailing), removal: .move(edge: .leading) @@ -40,7 +40,7 @@ struct AppView: View { case .mainTab: if let store = store.scope(state: \.mainTab, action: \.scope.mainTab) { - AppMainTabView(store: store) + MainTabView(store: store) .transition(.asymmetric( insertion: .move(edge: .trailing), removal: .move(edge: .leading) diff --git a/Projects/App/Sources/View/ContentView.swift b/Projects/App/Sources/View/ContentView.swift index c4eca408..3be00843 100644 --- a/Projects/App/Sources/View/ContentView.swift +++ b/Projects/App/Sources/View/ContentView.swift @@ -15,7 +15,7 @@ public struct ContentView: View { } #Preview { - AppAuthCoordinatorView(store: .init(initialState: AppAuthCoordinator.State(), reducer: { - AppAuthCoordinator() + AuthCoordinatorView(store: .init(initialState: AuthCoordinator.State(), reducer: { + AuthCoordinator() })) } diff --git a/Projects/App/Tests/Sources/AppDeeplinkRoutingTests.swift b/Projects/App/Tests/Sources/AppDeeplinkRoutingTests.swift index 980f9e14..af2397ec 100644 --- a/Projects/App/Tests/Sources/AppDeeplinkRoutingTests.swift +++ b/Projects/App/Tests/Sources/AppDeeplinkRoutingTests.swift @@ -37,13 +37,13 @@ struct AppDeeplinkRoutingTests { } await store.receive { guard case let .scope(.mainTab(.selectTab(tab))) = $0 else { return false } - return tab == AppMainTabCoordinator.Tab.quickBattle.rawValue + return tab == MainTabCoordinator.Tab.quickBattle.rawValue } guard case let .mainTab(state) = store.state else { Issue.record("메인 화면으로 전환되지 않음") return } - #expect(state.selectedTab == AppMainTabCoordinator.Tab.quickBattle.rawValue) + #expect(state.selectedTab == MainTabCoordinator.Tab.quickBattle.rawValue) #expect(pending == nil) await store.send(.async(.consumePendingDeeplink)) await store.finish() diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAudienceLevel.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAudienceLevel.swift new file mode 100644 index 00000000..c27b4775 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAudienceLevel.swift @@ -0,0 +1,13 @@ +public enum ClassAudienceLevel: String, CaseIterable, Equatable, Sendable { + case middle + case high + case adult + + public var title: String { + switch self { + case .middle: "중등" + case .high: "고등" + case .adult: "성인" + } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleSummary.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleSummary.swift new file mode 100644 index 00000000..5dcbf96e --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleSummary.swift @@ -0,0 +1,85 @@ +import Foundation + +public struct ClassBattleSummary: Equatable, Identifiable, Sendable { + public let id: Int + public let title: String + public let summary: String + public let thumbnailURL: URL? + public let philosopherA: String + public let optionATitle: String + public let philosopherAImageURL: URL? + public let philosopherB: String + public let optionBTitle: String + public let philosopherBImageURL: URL? + public let category: ClassCategory + public let level: ClassAudienceLevel + public let audioDuration: Int + public let viewCount: Int + + public init( + id: Int, + title: String, + summary: String, + thumbnailURL: URL? = nil, + philosopherA: String, + optionATitle: String, + philosopherAImageURL: URL? = nil, + philosopherB: String, + optionBTitle: String, + philosopherBImageURL: URL? = nil, + category: ClassCategory, + level: ClassAudienceLevel, + audioDuration: Int, + viewCount: Int + ) { + self.id = id + self.title = title + self.summary = summary + self.thumbnailURL = thumbnailURL + self.philosopherA = philosopherA + self.optionATitle = optionATitle + self.philosopherAImageURL = philosopherAImageURL + self.philosopherB = philosopherB + self.optionBTitle = optionBTitle + self.philosopherBImageURL = philosopherBImageURL + self.category = category + self.level = level + self.audioDuration = audioDuration + self.viewCount = viewCount + } + + public var durationMinutes: Int { + max(1, audioDuration / 60) + } +} + +public extension ClassBattleSummary { + static let mocks: [ClassBattleSummary] = [ + .init( + id: 101, + title: "촉법소년 연령을 낮춰야 할까?", + summary: "청소년 범죄와 보호의 균형을 이야기해요.", + philosopherA: "엄격한 처벌", + optionATitle: "연령을 낮춰야 한다", + philosopherB: "회복적 보호", + optionBTitle: "현행 연령을 유지한다", + category: .society, + level: .middle, + audioDuration: 5 * 60, + viewCount: 726 + ), + .init( + id: 102, + title: "뒤샹의 변기, 예술인가 도발인가", + summary: "예술의 경계를 함께 생각해요.", + philosopherA: "예술의 확장", + optionATitle: "예술이다", + philosopherB: "전통적 관점", + optionBTitle: "도발이다", + category: .art, + level: .high, + audioDuration: 6 * 60, + viewCount: 902 + ), + ] +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCategory.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCategory.swift new file mode 100644 index 00000000..045f9afe --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCategory.swift @@ -0,0 +1,19 @@ +public enum ClassCategory: String, CaseIterable, Equatable, Sendable { + case philosophy + case society + case literature + case science + case art + case history + + public var title: String { + switch self { + case .philosophy: "철학" + case .society: "사회" + case .literature: "문학" + case .science: "과학" + case .art: "예술" + case .history: "역사" + } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCreation.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCreation.swift new file mode 100644 index 00000000..849b314d --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCreation.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct ClassCreation: Equatable, Sendable { + public var name: String + public var deadline: Date + public var battleId: Int + public var allowsAnonymousOpinion: Bool + public var requiresComment: Bool + + public init( + name: String, + deadline: Date, + battleId: Int, + allowsAnonymousOpinion: Bool, + requiresComment: Bool + ) { + self.name = name + self.deadline = deadline + self.battleId = battleId + self.allowsAnonymousOpinion = allowsAnonymousOpinion + self.requiresComment = requiresComment + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassError.swift new file mode 100644 index 00000000..67eeeacd --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassError.swift @@ -0,0 +1,19 @@ +import Foundation + +public enum ClassError: Error, Equatable, Sendable { + case invalidCode + case closed + case alreadyJoined + case network(String) + case unknown(String) + + public static func from(_ error: Error) -> ClassError { + if let error = error as? ClassError { + return error + } + if let error = error as? URLError { + return .network(error.localizedDescription) + } + return .unknown(String(describing: error)) + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMember.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMember.swift new file mode 100644 index 00000000..a4d6cbd2 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMember.swift @@ -0,0 +1,11 @@ +public struct ClassMember: Equatable, Identifiable, Sendable { + public let id: Int + public let name: String + public let isOwner: Bool + + public init(id: Int, name: String, isOwner: Bool = false) { + self.id = id + self.name = name + self.isOwner = isOwner + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberAttendance.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberAttendance.swift new file mode 100644 index 00000000..dd4e2e4e --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberAttendance.swift @@ -0,0 +1,6 @@ +public enum ClassMemberAttendance: String, CaseIterable, Equatable, Sendable { + case all = "전체" + case changed = "입장 변화" + case unchanged = "입장 유지" + case inProgress = "진행 중" +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberCompletion.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberCompletion.swift new file mode 100644 index 00000000..43365761 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberCompletion.swift @@ -0,0 +1,6 @@ +public enum ClassMemberCompletion: String, CaseIterable, Equatable, Sendable { + case all = "전체" + case noComment = "댓글 미작성" + case noAfterVote = "사후 투표 미완료" + case inProgress = "진행 중" +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberParticipation.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberParticipation.swift new file mode 100644 index 00000000..997d76da --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberParticipation.swift @@ -0,0 +1,6 @@ +public enum ClassMemberParticipation: String, CaseIterable, Equatable, Sendable { + case all = "전체" + case notParticipated = "미참여" + case completed = "참여 완료" + case inProgress = "진행 중" +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberSort.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberSort.swift new file mode 100644 index 00000000..79d81463 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassMember/ClassMemberSort.swift @@ -0,0 +1,6 @@ +public enum ClassMemberSort: String, CaseIterable, Equatable, Sendable { + case name = "이름순" + case comments = "댓글 많은순" + case replies = "대댓글 많은순" + case recommendations = "추천순" +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerMember.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerMember.swift new file mode 100644 index 00000000..682b3580 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerMember.swift @@ -0,0 +1,18 @@ +public struct ClassOwnerMember: Equatable, Identifiable, Sendable { + public let id: Int + public let name: String + public let participationCount: Int + public let needsReview: Bool + + public init( + id: Int, + name: String, + participationCount: Int, + needsReview: Bool = false + ) { + self.id = id + self.name = name + self.participationCount = participationCount + self.needsReview = needsReview + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerMemberSort.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerMemberSort.swift new file mode 100644 index 00000000..496bf88b --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerMemberSort.swift @@ -0,0 +1,5 @@ +public enum ClassOwnerMemberSort: String, CaseIterable, Hashable, Sendable { + case name = "이름순" + case participation = "참여 많은순" + case needsReview = "확인 필요순" +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerOpinion.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerOpinion.swift new file mode 100644 index 00000000..e3c0a04b --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerOpinion.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct ClassOwnerOpinion: Equatable, Identifiable, Sendable { + public let id: Int + public let author: String + public let text: String + public let replyCount: Int + public let recommendationCount: Int? + public let createdAt: Date? + public let isReported: Bool + + public init( + id: Int, + author: String, + text: String, + replyCount: Int, + recommendationCount: Int? = nil, + createdAt: Date? = nil, + isReported: Bool = false + ) { + self.id = id + self.author = author + self.text = text + self.replyCount = replyCount + self.recommendationCount = recommendationCount + self.createdAt = createdAt + self.isReported = isReported + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerOpinionFilter.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerOpinionFilter.swift new file mode 100644 index 00000000..5fcd69c5 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerOpinionFilter.swift @@ -0,0 +1,4 @@ +public enum ClassOwnerOpinionFilter: String, CaseIterable, Hashable, Sendable { + case popular = "인기순" + case latest = "최신순" +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerTab.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerTab.swift new file mode 100644 index 00000000..0a2be3ab --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerTab.swift @@ -0,0 +1,5 @@ +public enum ClassOwnerTab: String, CaseIterable, Hashable, Sendable { + case home = "홈" + case members = "멤버" + case opinions = "의견" +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRole.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRole.swift new file mode 100644 index 00000000..4d3721c0 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRole.swift @@ -0,0 +1,4 @@ +public enum ClassRole: String, Equatable, Sendable { + case owner + case member +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRoom.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRoom.swift new file mode 100644 index 00000000..4af220b8 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRoom.swift @@ -0,0 +1,80 @@ +import Foundation + +public struct ClassRoom: Equatable, Identifiable, Sendable { + public let id: Int + public let name: String + public let joinCode: String + public let battle: ClassBattleSummary + public let deadline: Date + public let memberCount: Int + public let role: ClassRole + public let status: ClassStatus + public let allowsAnonymousOpinion: Bool + public let requiresComment: Bool + + public init( + id: Int, + name: String, + joinCode: String, + battle: ClassBattleSummary, + deadline: Date, + memberCount: Int, + role: ClassRole, + status: ClassStatus, + allowsAnonymousOpinion: Bool, + requiresComment: Bool + ) { + self.id = id + self.name = name + self.joinCode = joinCode + self.battle = battle + self.deadline = deadline + self.memberCount = memberCount + self.role = role + self.status = status + self.allowsAnonymousOpinion = allowsAnonymousOpinion + self.requiresComment = requiresComment + } +} + +public extension ClassRoom { + static let mocks: [ClassRoom] = [ + .init( + id: 1, + name: "1학년 3반 사회 토론", + joinCode: "PK7M2Q", + battle: ClassBattleSummary.mocks[0], + deadline: Date(timeIntervalSince1970: 1_799_000_000), + memberCount: 20, + role: .owner, + status: .open, + allowsAnonymousOpinion: true, + requiresComment: true + ), + .init( + id: 2, + name: "함께 생각하는 미술 수업", + joinCode: "PK4B8R", + battle: ClassBattleSummary.mocks[1], + deadline: Date(timeIntervalSince1970: 1_799_000_000), + memberCount: 18, + role: .member, + status: .open, + allowsAnonymousOpinion: true, + requiresComment: false + ), + ] + + static let mockJoinable = ClassRoom( + id: 3, + name: "사회 토론 수업", + joinCode: "PK9T3S", + battle: ClassBattleSummary.mocks[0], + deadline: Date(timeIntervalSince1970: 1_799_000_000), + memberCount: 12, + role: .member, + status: .open, + allowsAnonymousOpinion: true, + requiresComment: true + ) +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassStatus.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassStatus.swift new file mode 100644 index 00000000..1c83487d --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassStatus.swift @@ -0,0 +1,4 @@ +public enum ClassStatus: String, Equatable, Sendable { + case open + case closed +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicFilter.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicFilter.swift new file mode 100644 index 00000000..41635b2b --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicFilter.swift @@ -0,0 +1,15 @@ +public struct ClassTopicFilter: Equatable, Sendable { + public var keyword: String + public var level: ClassAudienceLevel + public var category: ClassCategory? + + public init( + keyword: String = "", + level: ClassAudienceLevel = .middle, + category: ClassCategory? = nil + ) { + self.keyword = keyword + self.level = level + self.category = category + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassInterface.swift new file mode 100644 index 00000000..973d5559 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassInterface.swift @@ -0,0 +1,36 @@ +import ComposableArchitecture +import Foundation + +public protocol ClassInterface: Sendable { + func fetchMyClasses() async throws -> [ClassRoom] + func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] + func createClass(_ creation: ClassCreation) async throws -> ClassRoom + func fetchClass(joinCode: String) async throws -> ClassRoom + func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom + func updateDeadline(id: Int, deadline: Date) async throws -> ClassRoom + func deleteClass(id: Int) async throws +} + +public enum ClassRepositoryDependency: TestDependencyKey { + public static var testValue: any ClassInterface { + MockClassRepository() + } +} + +public enum ClassUseCaseDependency: TestDependencyKey { + public static var testValue: any ClassInterface { + MockClassRepository() + } +} + +public extension DependencyValues { + var classRepository: any ClassInterface { + get { self[ClassRepositoryDependency.self] } + set { self[ClassRepositoryDependency.self] = newValue } + } + + var classUseCase: any ClassInterface { + get { self[ClassUseCaseDependency.self] } + set { self[ClassUseCaseDependency.self] = newValue } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository.swift new file mode 100644 index 00000000..1b1e925a --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository.swift @@ -0,0 +1,126 @@ +import Foundation + +public actor MockClassRepository: ClassInterface { + private var rooms: [ClassRoom] + private var availableRooms: [ClassRoom] + private var nextID: Int + + public init( + rooms: [ClassRoom] = ClassRoom.mocks, + availableRooms: [ClassRoom] = [ClassRoom.mockJoinable] + ) { + self.rooms = rooms + self.availableRooms = availableRooms + nextID = (rooms + availableRooms).map(\.id).max().map { $0 + 1 } ?? 1 + } + + public func fetchMyClasses() async throws -> [ClassRoom] { + rooms + } + + public func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] { + ClassBattleSummary.mocks.filter { battle in + let matchesKeyword = filter.keyword.isEmpty + || battle.title.localizedCaseInsensitiveContains(filter.keyword) + || battle.summary.localizedCaseInsensitiveContains(filter.keyword) + let matchesCategory = filter.category.map { battle.category == $0 } ?? true + return battle.level == filter.level && matchesCategory && matchesKeyword + } + } + + public func createClass(_ creation: ClassCreation) async throws -> ClassRoom { + guard !creation.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let battle = ClassBattleSummary.mocks.first(where: { $0.id == creation.battleId }) + else { + throw ClassError.unknown("클래스 이름 또는 배틀이 올바르지 않습니다.") + } + + let id = nextID + nextID += 1 + let room = ClassRoom( + id: id, + name: creation.name, + joinCode: String(format: "PK%04d", id), + battle: battle, + deadline: creation.deadline, + memberCount: 1, + role: .owner, + status: .open, + allowsAnonymousOpinion: creation.allowsAnonymousOpinion, + requiresComment: creation.requiresComment + ) + rooms.append(room) + return room + } + + public func fetchClass(joinCode: String) async throws -> ClassRoom { + guard let room = (rooms + availableRooms).first(where: { $0.joinCode == joinCode }) else { + throw ClassError.invalidCode + } + return room + } + + public func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom { + guard !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ClassError.unknown("이름을 입력해주세요.") + } + guard !rooms.contains(where: { $0.joinCode == joinCode }) else { + throw ClassError.alreadyJoined + } + guard let index = availableRooms.firstIndex(where: { $0.joinCode == joinCode }) else { + throw ClassError.invalidCode + } + + let room = availableRooms[index] + guard room.status == .open else { throw ClassError.closed } + let joined = ClassRoom( + id: room.id, + name: room.name, + joinCode: room.joinCode, + battle: room.battle, + deadline: room.deadline, + memberCount: room.memberCount + 1, + role: .member, + status: room.status, + allowsAnonymousOpinion: room.allowsAnonymousOpinion, + requiresComment: room.requiresComment + ) + rooms.append(joined) + availableRooms.remove(at: index) + return joined + } + + public func updateDeadline(id: Int, deadline: Date) async throws -> ClassRoom { + guard let index = rooms.firstIndex(where: { $0.id == id }) else { + throw ClassError.invalidCode + } + let room = rooms[index] + guard room.role == .owner else { + throw ClassError.unknown("클래스 관리자만 마감일을 변경할 수 있습니다.") + } + let updated = ClassRoom( + id: room.id, + name: room.name, + joinCode: room.joinCode, + battle: room.battle, + deadline: deadline, + memberCount: room.memberCount, + role: room.role, + status: room.status, + allowsAnonymousOpinion: room.allowsAnonymousOpinion, + requiresComment: room.requiresComment + ) + rooms[index] = updated + return updated + } + + public func deleteClass(id: Int) async throws { + guard let index = rooms.firstIndex(where: { $0.id == id }) else { + throw ClassError.invalidCode + } + guard rooms[index].role == .owner else { + throw ClassError.unknown("클래스 관리자만 삭제할 수 있습니다.") + } + rooms.remove(at: index) + } +} diff --git a/Projects/Domain/ClassDomain/Project.swift b/Projects/Domain/ClassDomain/Project.swift new file mode 100644 index 00000000..691c5847 --- /dev/null +++ b/Projects/Domain/ClassDomain/Project.swift @@ -0,0 +1,19 @@ +import DependencyPackagePlugin +import DependencyPlugin +import Foundation +import ProjectDescription +import ProjectTemplatePlugin + +let project = Project.makeModule( + name: "ClassDomain", + bundleId: .appBundleID(name: ".ClassDomain"), + product: .framework, + settings: .settings(), + dependencies: [], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .SPM.composableArchitecture, + ], + hasTesting: false +) diff --git a/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift b/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift new file mode 100644 index 00000000..9bebe255 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift @@ -0,0 +1,14 @@ +import ClassDomainInterface +import ComposableArchitecture + +extension ClassUseCaseDependency: DependencyKey { + public static var liveValue: any ClassInterface { + ClassUseCaseImpl() + } +} + +extension ClassRepositoryDependency: DependencyKey { + public static var liveValue: any ClassInterface { + ClassRepositoryImpl() + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Exported/ClassDomainExported.swift b/Projects/Domain/ClassDomain/Sources/Exported/ClassDomainExported.swift new file mode 100644 index 00000000..614f78a1 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Exported/ClassDomainExported.swift @@ -0,0 +1 @@ +@_exported import ClassDomainInterface diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift new file mode 100644 index 00000000..5797142b --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift @@ -0,0 +1,36 @@ +import ClassDomainInterface +import Foundation + +public struct ClassRepositoryImpl: ClassInterface { + private static let unavailableMessage = "클래스 서버 API가 연결되지 않았습니다." + + public init() {} + + public func fetchMyClasses() async throws -> [ClassRoom] { + throw ClassError.network(Self.unavailableMessage) + } + + public func fetchRecommendedBattles(filter _: ClassTopicFilter) async throws -> [ClassBattleSummary] { + throw ClassError.network(Self.unavailableMessage) + } + + public func createClass(_: ClassCreation) async throws -> ClassRoom { + throw ClassError.network(Self.unavailableMessage) + } + + public func fetchClass(joinCode _: String) async throws -> ClassRoom { + throw ClassError.network(Self.unavailableMessage) + } + + public func joinClass(joinCode _: String, nickname _: String) async throws -> ClassRoom { + throw ClassError.network(Self.unavailableMessage) + } + + public func updateDeadline(id _: Int, deadline _: Date) async throws -> ClassRoom { + throw ClassError.network(Self.unavailableMessage) + } + + public func deleteClass(id _: Int) async throws { + throw ClassError.network(Self.unavailableMessage) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl.swift new file mode 100644 index 00000000..6fc9fbb3 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl.swift @@ -0,0 +1,37 @@ +import ClassDomainInterface +import ComposableArchitecture +import Foundation + +public struct ClassUseCaseImpl: ClassInterface { + @Dependency(\.classRepository) private var repository + + public init() {} + + public func fetchMyClasses() async throws -> [ClassRoom] { + try await repository.fetchMyClasses() + } + + public func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] { + try await repository.fetchRecommendedBattles(filter: filter) + } + + public func createClass(_ creation: ClassCreation) async throws -> ClassRoom { + try await repository.createClass(creation) + } + + public func fetchClass(joinCode: String) async throws -> ClassRoom { + try await repository.fetchClass(joinCode: joinCode) + } + + public func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom { + try await repository.joinClass(joinCode: joinCode, nickname: nickname) + } + + public func updateDeadline(id: Int, deadline: Date) async throws -> ClassRoom { + try await repository.updateDeadline(id: id, deadline: deadline) + } + + public func deleteClass(id: Int) async throws { + try await repository.deleteClass(id: id) + } +} diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassDomainTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassDomainTests.swift new file mode 100644 index 00000000..7d8261a5 --- /dev/null +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassDomainTests.swift @@ -0,0 +1,116 @@ +import ClassDomain +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import Testing + +struct ClassDomainTests { + @Test + func live_저장소는_mock_클래스를_반환하지_않는다() async throws { + do { + _ = try await ClassRepositoryImpl().fetchMyClasses() + Issue.record("live 저장소가 서버 연결 없이 클래스 목록을 반환함") + } catch { + #expect(ClassError.from(error) == .network("클래스 서버 API가 연결되지 않았습니다.")) + } + } + + @Test + func useCase는_주입된_repository를_사용한다() async throws { + let room = ClassRoom.mocks[0] + let useCase = withDependencies { + $0.classRepository = MockClassRepository(rooms: [room], availableRooms: []) + } operation: { + ClassUseCaseImpl() + } + + #expect(try await useCase.fetchMyClasses() == [room]) + } + + @Test + func 생성한_클래스는_목록에_추가되고_선생님_역할을_갖는다() async throws { + let repository = MockClassRepository(rooms: [], availableRooms: []) + let created = try await repository.createClass( + ClassCreation( + name: "토론 수업", + deadline: Date(timeIntervalSince1970: 1_800_000_000), + battleId: 101, + allowsAnonymousOpinion: true, + requiresComment: false + ) + ) + + #expect(created.role == .owner) + #expect(created.battle.id == 101) + #expect(try await repository.fetchMyClasses() == [created]) + #expect(try await repository.fetchClass(joinCode: created.joinCode) == created) + } + + @Test + func 참여하면_학생_역할로_목록에_추가되고_중복_참여는_거부한다() async throws { + let repository = MockClassRepository(rooms: [], availableRooms: [.mockJoinable]) + let joined = try await repository.joinClass(joinCode: "PK9T3S", nickname: "민지") + + #expect(joined.role == .member) + #expect(joined.memberCount == ClassRoom.mockJoinable.memberCount + 1) + #expect(try await repository.fetchMyClasses() == [joined]) + do { + _ = try await repository.joinClass(joinCode: "PK9T3S", nickname: "민지") + Issue.record("중복 참여가 허용됨") + } catch { + #expect(ClassError.from(error) == .alreadyJoined) + } + } + + @Test + func 없는_참여_코드는_거부한다() async throws { + let repository = MockClassRepository(rooms: [], availableRooms: []) + do { + _ = try await repository.fetchClass(joinCode: "INVALID") + Issue.record("없는 참여 코드가 허용됨") + } catch { + #expect(ClassError.from(error) == .invalidCode) + } + } + + @Test + func 추천_배틀은_수준과_카테고리와_검색어로_걸러진다() async throws { + let repository = MockClassRepository() + let middle = try await repository.fetchRecommendedBattles( + filter: .init(keyword: "소년", level: .middle, category: .society) + ) + let high = try await repository.fetchRecommendedBattles( + filter: .init(level: .high, category: .society) + ) + + #expect(middle.map(\.id) == [101]) + #expect(high.isEmpty) + } + + @Test + func 마감일_변경은_목록과_참여코드_조회에_반영된다() async throws { + let repository = MockClassRepository(rooms: [ClassRoom.mocks[0]], availableRooms: []) + let deadline = Date(timeIntervalSince1970: 1_801_000_000) + + let updated = try await repository.updateDeadline(id: 1, deadline: deadline) + + #expect(updated.deadline == deadline) + #expect(try await repository.fetchMyClasses()[0].deadline == deadline) + #expect(try await repository.fetchClass(joinCode: updated.joinCode).deadline == deadline) + } + + @Test + func 클래스_삭제는_목록과_참여코드_조회에_반영된다() async throws { + let repository = MockClassRepository(rooms: [ClassRoom.mocks[0]], availableRooms: []) + + try await repository.deleteClass(id: 1) + + #expect(try await repository.fetchMyClasses().isEmpty) + do { + _ = try await repository.fetchClass(joinCode: "PK7M2Q") + Issue.record("삭제한 클래스를 조회할 수 있음") + } catch { + #expect(ClassError.from(error) == .invalidCode) + } + } +} diff --git a/Projects/Domain/DomainAssembly/Project.swift b/Projects/Domain/DomainAssembly/Project.swift index 4a3ba505..38e4c9bd 100644 --- a/Projects/Domain/DomainAssembly/Project.swift +++ b/Projects/Domain/DomainAssembly/Project.swift @@ -16,6 +16,7 @@ let project = Project.makeModule( .domain(.attendance, .implementation), .domain(.auth, .implementation), .domain(.battle, .implementation), + .domain(.classroom, .implementation), .domain(.ad, .implementation), .domain(.search, .implementation), .domain(.comment, .implementation), diff --git a/Projects/Domain/DomainAssembly/Sources/Exported/DomainAssemblyExported.swift b/Projects/Domain/DomainAssembly/Sources/Exported/DomainAssemblyExported.swift index 04377248..52b1e2a9 100644 --- a/Projects/Domain/DomainAssembly/Sources/Exported/DomainAssemblyExported.swift +++ b/Projects/Domain/DomainAssembly/Sources/Exported/DomainAssemblyExported.swift @@ -5,6 +5,8 @@ // MARK: - Domain 레이어 한번에 노출 +@_exported import AdDomain +@_exported import AdDomainInterface @_exported import AppUpdateDomain @_exported import AppUpdateDomainInterface @_exported import AttendanceDomain @@ -13,8 +15,8 @@ @_exported import AuthDomainInterface @_exported import BattleDomain @_exported import BattleDomainInterface -@_exported import AdDomain -@_exported import AdDomainInterface +@_exported import ClassDomain +@_exported import ClassDomainInterface @_exported import CommentDomain @_exported import CommentDomainInterface @_exported import HomeDomain diff --git a/Projects/Domain/DomainAssembly/Tests/Sources/DomainAssemblyExportTests.swift b/Projects/Domain/DomainAssembly/Tests/Sources/DomainAssemblyExportTests.swift index c92d8e7a..7e88105e 100644 --- a/Projects/Domain/DomainAssembly/Tests/Sources/DomainAssemblyExportTests.swift +++ b/Projects/Domain/DomainAssembly/Tests/Sources/DomainAssemblyExportTests.swift @@ -18,6 +18,7 @@ struct DomainAssemblyExportTests { (any AttendanceInterface).self, (any AuthUseCaseInterface).self, (any BattleInterface).self, + (any ClassInterface).self, (any CommentInterface).self, (any HomeInterface).self, (any NotificationInterface).self, @@ -26,6 +27,6 @@ struct DomainAssemblyExportTests { (any SearchInterface).self, ] - #expect(interfaces.count == 10) + #expect(interfaces.count == 11) } } diff --git a/Projects/Feature/Auth/Sources/Main/View/Components/TermsAgreementView.swift b/Projects/Feature/Auth/Sources/Main/View/Components/TermsAgreementView.swift index d57dc51b..ea799af7 100644 --- a/Projects/Feature/Auth/Sources/Main/View/Components/TermsAgreementView.swift +++ b/Projects/Feature/Auth/Sources/Main/View/Components/TermsAgreementView.swift @@ -3,19 +3,19 @@ // Auth // -import SwiftUI - import AuthDomainInterface import ComposableArchitecture import PickeDesignKit +import SwiftUI struct TermsAgreementView: View { @Bindable var store: StoreOf var body: some View { ZStack(alignment: .bottom) { - Color.black.opacity(0.4) + Color.clear .ignoresSafeArea() + .contentShape(Rectangle()) .onTapGesture { store.send(.view(.dismissTapped)) } sheet() diff --git a/Projects/Feature/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Feature/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index ba80ffcd..ecdf1552 100644 --- a/Projects/Feature/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Feature/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -73,59 +73,12 @@ public struct ChatRoomFeature { // scenario 가 nil(미로드/로드 실패)이면 목업을 노출하지 않는다. // 과거엔 bundle.messages(목업 전체)를 반환해 "재생 안 됨 + 전체 내용 노출" 버그가 있었다. guard let scenario else { return [] } - let nodes = visibleNodes(in: scenario) - 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 - } - - 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 - } - } + return ChatRoomTimelineUseCase().messages( + scenario: scenario, + visibleNodeIds: visibleNodeIds, + currentNodeId: currentNodeId, + currentTime: currentTime + ) } /// 현재 재생 중(가장 최근 노출된) 메시지 id. 문장 단위 노출이라 마지막 노출 버블이 활성. @@ -272,67 +225,6 @@ 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/Feature/Chat/Sources/ChatRoom/UseCase/ChatRoomTimelineUseCase.swift b/Projects/Feature/Chat/Sources/ChatRoom/UseCase/ChatRoomTimelineUseCase.swift new file mode 100644 index 00000000..a286bcbf --- /dev/null +++ b/Projects/Feature/Chat/Sources/ChatRoom/UseCase/ChatRoomTimelineUseCase.swift @@ -0,0 +1,144 @@ +import Foundation +import HomeDomainInterface +import PickeCoreUtility + +/// 시나리오와 재생 시각에서 화면에 표시할 메시지를 계산한다. +struct ChatRoomTimelineUseCase { + func messages( + scenario: BattleScenario, + visibleNodeIds: [Int], + currentNodeId: Int?, + currentTime: TimeInterval + ) -> [ChatMessage] { + let nodes = visibleNodes( + in: scenario, + visibleNodeIds: visibleNodeIds, + currentNodeId: currentNodeId + ) + let currentMs = Int(currentTime * 1000) + return nodes.flatMap { messages(for: $0, in: scenario, currentMs: currentMs) } + } + + private func messages( + for node: ScenarioNode, + in scenario: BattleScenario, + currentMs: Int + ) -> [ChatMessage] { + 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 + } + + 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 + } + } + + private func visibleNodes( + in scenario: BattleScenario, + visibleNodeIds: [Int], + currentNodeId: Int? + ) -> [ScenarioNode] { + let ids = visibleNodeIds.isEmpty ? [currentNodeId ?? scenario.startNodeId] : visibleNodeIds + return ids.compactMap { id in + scenario.nodes.first { $0.nodeId == id } + } + } + + 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 + ) + } +} diff --git a/Projects/Feature/Chat/Tests/Sources/ChatTests.swift b/Projects/Feature/Chat/Tests/Sources/ChatTests.swift index a505aae6..fde5d4ec 100644 --- a/Projects/Feature/Chat/Tests/Sources/ChatTests.swift +++ b/Projects/Feature/Chat/Tests/Sources/ChatTests.swift @@ -6,6 +6,7 @@ // @testable import Chat +import HomeDomainInterface import Testing struct ChatTests { @@ -20,4 +21,72 @@ struct ChatTests { let result = true #expect(result == true) } + + @Test + func chatRoomMessagesRemainHiddenUntilTheirTimelinePosition() { + var state = ChatRoomFeature.State(battleId: 901) + state.scenario = makeScenario() + state.currentNodeId = 1 + state.visibleNodeIds = [1] + + state.currentTime = 1 + #expect(state.messages.map(\.text) == ["첫 번째 문장."]) + + state.currentTime = 6 + #expect(state.messages.map(\.text) == ["첫 번째 문장.", "두 번째 문장."]) + } + + @Test + func chatRoomMessagesDoNotFallbackToMockContentBeforeScenarioLoads() { + let state = ChatRoomFeature.State(battleId: 902) + + #expect(state.messages.isEmpty) + #expect(state.audioUrl == nil) + } + + @Test + func chatRoomMessagesFollowDistinctScriptStartTimes() { + var state = ChatRoomFeature.State(battleId: 903) + state.scenario = makeScenario(scripts: [ + ScenarioScript(scriptId: 11, startTimeMs: 0, speakerType: .narrator, speakerName: "해설", text: "시작"), + ScenarioScript(scriptId: 12, startTimeMs: 7000, speakerType: .narrator, speakerName: "해설", text: "나중"), + ]) + state.visibleNodeIds = [1] + + state.currentTime = 6 + #expect(state.messages.map(\.text) == ["시작"]) + state.currentTime = 7 + #expect(state.messages.map(\.text) == ["시작", "나중"]) + #expect(state.messages.last?.speaker.side == .center) + } + + private func makeScenario(scripts: [ScenarioScript]? = nil) -> BattleScenario { + BattleScenario( + battleId: 901, + title: "테스트 배틀", + philosophers: [], + isInteractive: false, + startNodeId: 1, + recommendedPathKey: .common, + audios: ["COMMON": "https://example.com/audio.mp3"], + nodes: [ + ScenarioNode( + nodeId: 1, + nodeName: "START", + audioDuration: 10, + autoNextNodeId: nil, + scripts: scripts ?? [ + ScenarioScript( + scriptId: 1, + startTimeMs: 0, + speakerType: .narrator, + speakerName: "나레이터", + text: "첫 번째 문장. 두 번째 문장." + ), + ], + interactiveOptions: [] + ), + ] + ) + } } diff --git a/Projects/Feature/Class/Interface/Sources/ClassFeatureInterface.swift b/Projects/Feature/Class/Interface/Sources/ClassFeatureInterface.swift new file mode 100644 index 00000000..2b94582b --- /dev/null +++ b/Projects/Feature/Class/Interface/Sources/ClassFeatureInterface.swift @@ -0,0 +1,3 @@ +import ClassDomainInterface + +public enum ClassFeatureInterface {} diff --git a/Projects/Feature/Class/Project.swift b/Projects/Feature/Class/Project.swift new file mode 100644 index 00000000..566eb2b8 --- /dev/null +++ b/Projects/Feature/Class/Project.swift @@ -0,0 +1,27 @@ +import Foundation + +import DependencyPackagePlugin +import DependencyPlugin +import ProjectTemplatePlugin + +import ProjectDescription + +let project = Project.makeModule( + name: "Class", + bundleId: .appBundleID(name: ".Class"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .core(.coreUtility), + .ui(.designKit), + .ui(.sharedUI), + .domain(.classroom, .interface), + .SPM.composableArchitecture, + ], + hasTests: true, + hasInterface: true, + interfaceDependencies: [ + .domain(.classroom, .interface), + ], + hasTesting: false +) diff --git a/Projects/Feature/Class/Sources/ClassDetail/Reducer/ClassDetailFeature.swift b/Projects/Feature/Class/Sources/ClassDetail/Reducer/ClassDetailFeature.swift new file mode 100644 index 00000000..dbbc7840 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassDetail/Reducer/ClassDetailFeature.swift @@ -0,0 +1,218 @@ +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import PickeSharedUI + +@Reducer +public struct ClassDetailFeature { + @Dependency(\.classUseCase) private var classUseCase + + public init() {} + + @ObservableState + public struct State: Equatable { + public var room: ClassRoom + public var deadline: Date + public var draftDeadline: Date + /// The nickname draft is kept locally until the class API exposes an update contract. + public var draftNickname = "" + @Presents public var modal: ClassModalFeature.State? + @Presents public var customAlert: CustomAlertState? + public var isLoading = false + public var errorMessage: String? + + public init(room: ClassRoom) { + self.room = room + deadline = room.deadline + draftDeadline = room.deadline + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case modal(PresentationAction) + case customAlert(PresentationAction) + case delegate(DelegateAction) + } + + public enum View { + case backTapped + case membersTapped + case battleTapped + case reportTapped + case managementTapped + case managementDismissed + case codeTapped + case codeDismissed + case deadlineTapped + case deadlineSaved + case nicknameTapped + case nicknameSaved + case deleteTapped + case deleteConfirmed + } + + public enum DelegateAction: Equatable { + case dismiss + case openMembers(ClassRoom) + case openBattle(ClassBattleSummary) + case openReport(ClassRoom) + case deleted(Int) + } + + public enum AsyncAction: Equatable { + case updateDeadline(Int, Date) + case delete(Int) + } + + public enum InnerAction: Equatable { + case deadlineUpdated(Result) + case deleted(Int, ClassError?) + } + + 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 .modal(.presented(.dismissTapped)), .modal(.dismiss): + state.modal = nil + return .none + case .modal: + return .none + case let .customAlert(alertAction): + switch alertAction { + case .presented(.confirmTapped): + state.customAlert = nil + return handleViewAction(state: &state, action: .deleteConfirmed) + case .presented(.cancelTapped), .dismiss: + state.customAlert = nil + return .none + } + case .delegate: + return .none + } + } + .ifLet(\.$modal, action: \.modal) { + ClassModalFeature() + } + .ifLet(\.$customAlert, action: \.customAlert) { + CustomConfirmAlert() + } + } +} + +extension ClassDetailFeature { + private func handleViewAction(state: inout State, action: View) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + case .membersTapped: + return .send(.delegate(.openMembers(state.room))) + case .battleTapped: + return .send(.delegate(.openBattle(state.room.battle))) + case .reportTapped: + return .send(.delegate(.openReport(state.room))) + case .managementTapped: + guard state.room.role == .owner else { return .none } + state.modal = .init(kind: .management) + return .none + case .managementDismissed: + state.modal = nil + return .none + case .codeTapped: + state.modal = .init(kind: .code) + return .none + case .codeDismissed: + state.modal = nil + return .none + case .deadlineTapped: + guard state.room.role == .owner else { return .none } + state.modal = .init(kind: .deadline) + state.draftDeadline = state.deadline + return .none + case .deadlineSaved: + guard !state.isLoading else { return .none } + return .send(.async(.updateDeadline(state.room.id, state.draftDeadline))) + case .nicknameTapped: + state.modal = .init(kind: .nickname) + return .none + case .nicknameSaved: + guard !state.draftNickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return .none } + // There is currently no server contract for updating a class nickname. + // Keep the entered value in the feature and close the modal until one exists. + state.draftNickname = state.draftNickname.trimmingCharacters(in: .whitespacesAndNewlines) + state.modal = nil + return .none + case .deleteTapped: + guard state.room.role == .owner else { return .none } + state.modal = nil + state.customAlert = CustomAlertState( + title: "삭제 후에는 복구할 수 없어요.\n그럼에도 삭제하시겠습니까?", + confirmTitle: "삭제하기", + cancelTitle: "뒤로가기", + isDestructive: true, + style: .deleteConfirm + ) + return .none + case .deleteConfirmed: + guard state.room.role == .owner, !state.isLoading else { return .none } + state.customAlert = nil + return .send(.async(.delete(state.room.id))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + state.isLoading = true + state.errorMessage = nil + switch action { + case let .updateDeadline(id, deadline): + return .run { [classUseCase] send in + let result = await Result { try await classUseCase.updateDeadline(id: id, deadline: deadline) } + .mapError(ClassError.from) + await send(.inner(.deadlineUpdated(result))) + } + case let .delete(id): + return .run { [classUseCase] send in + do { + try await classUseCase.deleteClass(id: id) + await send(.inner(.deleted(id, nil))) + } catch { + await send(.inner(.deleted(id, ClassError.from(error)))) + } + } + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + state.isLoading = false + switch action { + case let .deadlineUpdated(.success(room)): + state.room = room + state.deadline = room.deadline + state.modal = nil + return .none + case let .deadlineUpdated(.failure(error)), let .deleted(_, .some(error)): + state.errorMessage = error.localizedDescription + return .none + case let .deleted(id, .none): + return .send(.delegate(.deleted(id))) + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassDetail/View/ClassDetailView.swift b/Projects/Feature/Class/Sources/ClassDetail/View/ClassDetailView.swift new file mode 100644 index 00000000..0cc56c1f --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassDetail/View/ClassDetailView.swift @@ -0,0 +1,387 @@ +import ClassDomainInterface +import ComposableArchitecture +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassDetailFeature.self) +public struct ClassDetailView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "클래스 상세") { + if store.room.role == .owner { + Button { send(.managementTapped) } label: { + Image(systemName: "ellipsis") + .font(.system(size: 20)) + .frame(width: 24, height: 24) + } + .accessibilityLabel("클래스 관리") + } + } + .foregroundStyle(.gray800) + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 16) { + headerSection() + classInformation() + } + battleSection() + actionButtons() + } + .padding(16) + } + .scrollIndicators(.hidden) + } + .screenBackground() + .toolbar(.hidden, for: .navigationBar) + .toolbar(store.modal == nil ? .visible : .hidden, for: .tabBar) + .pickeModal( + $store.scope(state: \.modal, action: \.modal), + dimOpacity: 0.28 + ) { modalStore in + switch modalStore.kind { + case .management: + modalBackdrop({ send(.managementDismissed) }) { managementSheet() } + case .deadline: + modalBackdrop({ modalStore.send(.dismissTapped) }) { deadlineSheet() } + case .code: + modalBackdrop({ send(.codeDismissed) }) { codeSheet() } + case .nickname: + modalBackdrop({ modalStore.send(.dismissTapped) }) { nicknameSheet() } + } + } + .customAlert($store.scope(state: \.customAlert, action: \.customAlert)) + .alert( + "요청을 완료하지 못했어요", + isPresented: Binding( + get: { store.errorMessage != nil }, + set: { + if !$0 { + store.errorMessage = nil + } + } + ) + ) { + Button("확인", role: .cancel) {} + } message: { + Text(store.errorMessage ?? "다시 시도해 주세요.") + } + } +} + +private extension ClassDetailView { + @ViewBuilder + func headerSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(store.room.status == .open ? "진행 중" : "종료") + .pretendardFont(.medium13) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + + Text(store.room.name) + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + .contextMenu { + if store.room.role == .member { + Button("내 이름 수정") { send(.nicknameTapped) } + } + } + } + } + + @ViewBuilder + func classInformation() -> some View { + VStack(spacing: 0) { + Button { send(.membersTapped) } label: { + informationRow( + "클래스 멤버", + detail: "총 \(store.room.memberCount)명이 참여하고 있어요", + actionTitle: "보기" + ) + } + .buttonStyle(.plain) + Rectangle().fill(.beige600).frame(height: 1) + Button { send(.deadlineTapped) } label: { + informationRow( + "참여 마감일", + detail: Self.deadlineFormatter.string(from: store.deadline) + "까지", + actionTitle: "수정" + ) + } + .buttonStyle(.plain) + .disabled(store.room.role != .owner) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + } + + @ViewBuilder + func informationRow(_ title: String, detail: String, actionTitle: String) -> some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + Text(detail) + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + Spacer(minLength: 0) + HStack(spacing: 4) { + Text(actionTitle) + .pretendardFont(family: .Bold, size: 12) + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + } + .foregroundStyle(.primary500) + } + .frame(height: 64) + } + + static let deadlineFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy. MM. dd. EEEE HH:mm" + return formatter + }() + + @ViewBuilder + func battleSection() -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("이번 클래스 배틀") + .pretendardFont(.semiBold15) + .foregroundStyle(.gray300) + + Button { send(.battleTapped) } label: { + HStack(spacing: 8) { + AsyncImage(url: store.room.battle.thumbnailURL) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.beige600 + } + .frame(width: 76, height: 94) + .clipShape(RoundedRectangle(cornerRadius: 2)) + + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text("#\(store.room.battle.category.title)") + .pickeBadge(.filled, size: .tag) + Text(store.room.battle.title) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray500) + .lineLimit(1) + } + Text(store.room.battle.summary) + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + .lineLimit(2) + } + HStack(spacing: 2) { + Spacer(minLength: 0) + Image(systemName: "clock") + .font(.system(size: 12)) + Text("\(store.room.battle.durationMinutes)분") + .pretendardFont(family: .Medium, size: 12) + } + .foregroundStyle(.gray300) + } + .frame(height: 94) + Spacer(minLength: 0) + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + } + .buttonStyle(.plain) + } + } + + var deleteButton: some View { + managementRow("클래스 삭제", detail: "삭제 후에는 복구할 수 없어요.", icon: "trash") { + send(.deleteTapped) + } + } + + @ViewBuilder + func actionButtons() -> some View { + VStack(spacing: 12) { + Button { send(.codeTapped) } label: { + Text("참여 코드 보기") + .pretendardFont(.headingMedium) + .foregroundStyle(.primary500) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + Button("결과 보기") { send(.reportTapped) } + .ctaButtonStyle(.primary, size: .large, height: 52) + } + } + + @ViewBuilder + func modalBackdrop( + _ onDismiss: @escaping () -> Void, + @ViewBuilder content: () -> some View + ) -> some View { + ZStack(alignment: .bottom) { + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + content() + .frame(maxWidth: .infinity) + .background( + UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26) + .fill(.beige50) + .ignoresSafeArea(edges: .bottom) + ) + } + } + + var sheetHandle: some View { + Capsule() + .fill(.gray50) + .frame(width: 40, height: 4) + .frame(maxWidth: .infinity) + .padding(.bottom, 10) + } + + @ViewBuilder + func managementSheet() -> some View { + VStack(alignment: .leading, spacing: 20) { + sheetHandle + Text("클래스 관리") + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + VStack(spacing: 0) { + managementRow("클래스 정보 수정", detail: "클래스 기본 정보를 수정해요.", icon: "pencil") { + send(.deadlineTapped) + } + Rectangle().fill(.beige600).frame(height: 1) + ShareLink(item: "[Picke] '\(store.room.name)' 클래스 참여 코드: \(store.room.joinCode)") { + managementRowContent("참여 코드 공유", detail: "클래스 참여 코드를 공유해요.", icon: "square.and.arrow.up") + } + .buttonStyle(.plain) + Rectangle().fill(.beige600).frame(height: 1) + deleteButton + } + } + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + + @ViewBuilder + func managementRow(_ title: String, detail: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + managementRowContent(title, detail: detail, icon: icon) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func managementRowContent(_ title: String, detail: String, icon: String) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundStyle(.primary500) + .frame(width: 40, height: 40) + .background(.primary50, in: Circle()) + VStack(alignment: .leading, spacing: 2) { + Text(title).pretendardFont(.semiBold15).foregroundStyle(.gray800) + Text(detail).pretendardFont(.medium13).foregroundStyle(.gray300) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundStyle(.gray800) + } + .frame(height: 72) + .contentShape(Rectangle()) + } + + @ViewBuilder + func deadlineSheet() -> some View { + VStack(alignment: .leading, spacing: 20) { + sheetHandle + Text("마감일 수정") + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + + DatePicker("마감일", selection: $store.draftDeadline, displayedComponents: [.date, .hourAndMinute]) + .datePickerStyle(.compact) + + Button("수정하기") { send(.deadlineSaved) } + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(store.isLoading) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 40) + } + + @ViewBuilder + func codeSheet() -> some View { + VStack(alignment: .leading, spacing: 20) { + sheetHandle + Text("참여 코드") + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + Text(store.room.joinCode) + .pretendardFont(.semiBold24) + .foregroundStyle(.primary500) + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + ShareLink(item: "[Picke] '\(store.room.name)' 클래스 참여 코드: \(store.room.joinCode)") { + Text("코드 공유하기") + .frame(maxWidth: .infinity) + } + .ctaButtonStyle(.primary, size: .large, height: 52) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 40) + } + + @ViewBuilder + func nicknameSheet() -> some View { + VStack(alignment: .leading, spacing: 20) { + sheetHandle + VStack(alignment: .leading, spacing: 6) { + Text("이름을 입력해주세요") + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + Text("이 클래스에서만 보이는 이름이에요.") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + + TextField("이름을 입력해주세요", text: $store.draftNickname) + .textInputAutocapitalization(.never) + .pickeTextField(height: 52) + + Button("수정하기") { send(.nicknameSaved) } + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(store.draftNickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } +} diff --git a/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift b/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift new file mode 100644 index 00000000..86a91100 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift @@ -0,0 +1,160 @@ +// +// ClassIntroFeature.swift +// Class +// + +import ClassDomainInterface +import ComposableArchitecture +import Foundation + +@Reducer +public struct ClassIntroFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + @Presents public var join: ClassJoinFeature.State? + public var showTicketNotice = false + + public init() {} + } + + public enum Action: ViewAction { + case view(View) + case join(PresentationAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backTapped + case joinTapped + case myClassesTapped + case createTapped + case ticketTapped + case ticketNoticeDismissed + } + + @CasePathable + public enum InnerAction: Equatable { + case nicknameModalPresented(ClassRoom) + } + + @CasePathable + public enum DelegateAction: Equatable { + case backToHome + case joined(ClassRoom) + case myClasses + case create + case ticket + } + + nonisolated enum CancelID: Hashable { + case nicknameModal + } + + @Dependency(\.continuousClock) private var clock + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .view(viewAction): + // 이름 입력 모달 대기 중 다른 탭이 들어오면 예약된 모달을 취소한다. + return .merge( + .cancel(id: CancelID.nicknameModal), + handleViewAction( + state: &state, + action: viewAction + ) + ) + + case let .join(presentationAction): + return handleJoinAction( + state: &state, + action: presentationAction + ) + + case let .inner(innerAction): + return handleInnerAction( + state: &state, + action: innerAction + ) + + case .delegate: + return .none + } + } + .ifLet(\.$join, action: \.join) { + ClassJoinFeature() + } + } +} + +extension ClassIntroFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.backToHome)) + + case .joinTapped: + state.join = .init() + return .none + + case .myClassesTapped: + return .send(.delegate(.myClasses)) + + case .createTapped: + return .send(.delegate(.create)) + + case .ticketTapped: + state.showTicketNotice = true + return .none + + case .ticketNoticeDismissed: + state.showTicketNotice = false + return .none + } + } + + private func handleJoinAction( + state: inout State, + action: PresentationAction + ) -> Effect { + switch action { + case .presented(.delegate(.dismiss)): + state.join = nil + return .none + + case let .presented(.delegate(.found(room))): + state.join = nil + // PickeModal exit 애니메이션(0.3초)이 끝난 뒤 이름 입력 모달을 새로 띄운다. + return .run { [clock] send in + try await clock.sleep(for: .seconds(0.3)) + await send(.inner(.nicknameModalPresented(room))) + } + .cancellable(id: CancelID.nicknameModal, cancelInFlight: true) + + case let .presented(.delegate(.joined(room))): + state.join = nil + return .send(.delegate(.joined(room))) + + case .presented, .dismiss: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .nicknameModalPresented(room): + state.join = .init(mode: .nickname, preview: room) + return .none + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift b/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift new file mode 100644 index 00000000..87f07e50 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift @@ -0,0 +1,139 @@ +// +// ClassIntroView.swift +// Class +// + +import ComposableArchitecture +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassIntroFeature.self) +public struct ClassIntroView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar( + onBack: { send(.backTapped) }, + centerTitle: "클래스" + ) + .foregroundStyle(.beige50) + + VStack(alignment: .leading, spacing: 0) { + titleSection() + Spacer(minLength: 20) + menuCards() + .padding(.bottom, 6) + createButton + ticketButton + } + .padding(.top, 24) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .background(backgroundImage()) + .toolbar(.hidden, for: .navigationBar) + .pickeModal( + $store.scope( + state: \.join, + action: \.join + ), + dimOpacity: 0.28 + ) { joinStore in + ClassJoinView(store: joinStore) + } + .alert( + "이용권 등록 준비 중", + isPresented: Binding( + get: { store.showTicketNotice }, + set: { + if !$0 { + send(.ticketNoticeDismissed) + } + } + ) + ) { + Button("확인", role: .cancel) {} + } message: { + Text("이용권 등록 기능은 아직 사용할 수 없습니다.") + } + } +} + +private extension ClassIntroView { + @ViewBuilder + func backgroundImage() -> some View { + ZStack { + Color.beige200 + Image(asset: .classIntroBackground) + .resizable() + .scaledToFill() + } + .ignoresSafeArea() + } + + @ViewBuilder + func titleSection() -> some View { + VStack(spacing: 12) { + Text("함께 생각하는\n수업을 시작해 보세요") + .pretendardFont(family: .SemiBold, size: 24) + .kerning(-0.6) + .lineSpacing(2.4) + .multilineTextAlignment(.center) + .foregroundStyle(.beige50) + + Text("콘텐츠를 고르고, 클래스로 함께 나눠요.") + .pretendardFont(family: .SemiBold, size: 16) + .kerning(-0.4) + .multilineTextAlignment(.center) + .foregroundStyle(.gray300) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + func menuCards() -> some View { + HStack(spacing: 6) { + ClassMenuCard( + title: "클래스 참여하기", + description: "참여 코드 입력", + image: .classJoin, + action: { send(.joinTapped) } + ) + + ClassMenuCard( + title: "내 클래스", + description: "참여중인 클래스", + image: .classMine, + action: { send(.myClassesTapped) } + ) + } + } + + var createButton: some View { + Button { + send(.createTapped) + } label: { + Text("새 클래스 만들기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + } + + var ticketButton: some View { + Button { + send(.ticketTapped) + } label: { + Text("이용권 등록하기") + .pretendardFont(.labelSmall) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Feature/Class/Sources/ClassIntro/View/Components/ClassMenuCard.swift b/Projects/Feature/Class/Sources/ClassIntro/View/Components/ClassMenuCard.swift new file mode 100644 index 00000000..f8393860 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassIntro/View/Components/ClassMenuCard.swift @@ -0,0 +1,46 @@ +// +// ClassMenuCard.swift +// Class +// + +import PickeDesignKit +import SwiftUI + +/// 클래스 인트로의 진입 카드 (참여하기 / 내 클래스). +struct ClassMenuCard: View { + let title: String + let description: String + let image: ImageAsset + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .pretendardFont(.headingMedium) + .foregroundStyle(.beige50) + + Text(description) + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .frame(height: 118, alignment: .top) + .background(alignment: .topLeading) { + Image(asset: image) + .resizable() + .scaledToFit() + .frame(width: 130, height: 130) + .offset(x: 59, y: 38) + } + .background(.gray700, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.gray800, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Feature/Class/Sources/ClassJoin/Reducer/ClassJoinFeature.swift b/Projects/Feature/Class/Sources/ClassJoin/Reducer/ClassJoinFeature.swift new file mode 100644 index 00000000..28e1de04 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassJoin/Reducer/ClassJoinFeature.swift @@ -0,0 +1,172 @@ +import ClassDomainInterface +import ComposableArchitecture +import Foundation + +@Reducer +public struct ClassJoinFeature { + @Dependency(\.classUseCase) private var classUseCase + + public init() {} + + public enum Mode: Equatable { + case code + case nickname + } + + @ObservableState + public struct State: Equatable { + public var mode: Mode + public var joinCode = "" + public var nickname = "" + public var preview: ClassRoom? + public var isLoading = false + public var errorMessage: String? + + public init(mode: Mode = .code, preview: ClassRoom? = nil) { + self.mode = mode + self.preview = preview + } + + public var canFindClass: Bool { + mode == .code && !joinCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoading + } + + public var canJoin: Bool { + mode == .nickname && preview != nil && !nickname.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty && !isLoading + } + } + + 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 backTapped + case findTapped + case joinTapped + } + + public enum AsyncAction: Equatable { + case find(String) + case join(joinCode: String, nickname: String) + } + + public enum InnerAction: Equatable { + case found(Result) + case joined(Result) + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + case found(ClassRoom) + case joined(ClassRoom) + } + + nonisolated enum CancelID: Hashable { + case find + case join + } + + 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 + } + } + } +} + +private extension ClassJoinFeature { + func handleViewAction(state: inout State, action: View) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + + case .findTapped: + guard state.canFindClass else { return .none } + let code = state.joinCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + return .send(.async(.find(code))) + + case .joinTapped: + guard state.canJoin, let room = state.preview else { return .none } + let nickname = state.nickname.trimmingCharacters(in: .whitespacesAndNewlines) + return .send(.async(.join(joinCode: room.joinCode, nickname: nickname))) + } + } + + func handleAsyncAction(state: inout State, action: AsyncAction) -> Effect { + switch action { + case let .find(code): + state.isLoading = true + state.errorMessage = nil + return .run { [classUseCase] send in + let result = await Result { + try await classUseCase.fetchClass(joinCode: code) + } + .mapError(ClassError.from) + await send(.inner(.found(result))) + } + .cancellable(id: CancelID.find, cancelInFlight: true) + + case let .join(code, nickname): + state.isLoading = true + state.errorMessage = nil + return .run { [classUseCase] send in + let result = await Result { + try await classUseCase.joinClass(joinCode: code, nickname: nickname) + } + .mapError(ClassError.from) + await send(.inner(.joined(result))) + } + .cancellable(id: CancelID.join, cancelInFlight: true) + } + } + + func handleInnerAction(state: inout State, action: InnerAction) -> Effect { + state.isLoading = false + switch action { + case let .found(.success(room)): + return .send(.delegate(.found(room))) + + case let .joined(.success(room)): + state.preview = nil + return .send(.delegate(.joined(room))) + + case let .found(.failure(error)), let .joined(.failure(error)): + state.errorMessage = error.message + return .none + } + } +} + +private extension ClassError { + var message: String { + switch self { + case .invalidCode: return "참여 코드를 다시 확인해 주세요." + case .closed: return "종료된 클래스입니다." + case .alreadyJoined: return "이미 참여한 클래스입니다." + case let .network(message), let .unknown(message): return message + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift b/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift new file mode 100644 index 00000000..f7f37083 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift @@ -0,0 +1,162 @@ +// +// ClassJoinView.swift +// Class +// + +import ClassDomainInterface +import ComposableArchitecture +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassJoinFeature.self) +public struct ClassJoinView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack(alignment: .bottom) { + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { send(.backTapped) } + switch store.mode { + case .code: + codeSheet() + case .nickname: + nicknameSheet() + } + } + } +} + +private extension ClassJoinView { + @ViewBuilder + func codeSheet() -> some View { + VStack(alignment: .leading, spacing: 20) { + sheetHandle + contentSection() + primaryButton + } + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 32) + .frame(maxWidth: .infinity) + .background( + UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26) + .fill(.beige50) + .ignoresSafeArea(edges: .bottom) + ) + } + + @ViewBuilder + func contentSection() -> some View { + VStack(alignment: .leading, spacing: 20) { + titleSection() + + codeSection() + + if let errorMessage = store.errorMessage { + Text(errorMessage) + .pretendardFont(.bodySmall) + .foregroundStyle(.errorDefault) + } + } + } + + @ViewBuilder + func titleSection() -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("참여 코드를 입력해주세요") + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + + Text("선생님에게 받은 코드를 입력하면 클래스에 참여할 수 있어요.") + .pretendardFont(.medium15) + .foregroundStyle(.gray300) + } + } + + @ViewBuilder + func codeSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("참여 코드") + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + + TextField( + "예) PK9T3S", + text: $store.joinCode + ) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .pickeTextField() + } + } + + @ViewBuilder + func nicknameSheet() -> some View { + VStack(alignment: .leading, spacing: 20) { + sheetHandle + + VStack(alignment: .leading, spacing: 6) { + Text("이름을 입력해주세요") + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + Text("이 클래스에서만 보이는 이름이에요.\n입장 후에도 수정할 수 있어요.") + .pretendardFont(family: .Regular, size: 14) + .foregroundStyle(.gray300) + } + + nicknameInput + if let errorMessage = store.errorMessage { + Text(errorMessage) + .pretendardFont(.bodySmall) + .foregroundStyle(.errorDefault) + } + joinButton + } + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 32) + .frame(maxWidth: .infinity) + .background( + UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26) + .fill(.beige50) + .ignoresSafeArea(edges: .bottom) + ) + } + + var sheetHandle: some View { + Capsule() + .fill(.gray50) + .frame(width: 40, height: 4) + .frame(maxWidth: .infinity) + .padding(.bottom, 10) + } + + var nicknameInput: some View { + TextField("이름을 입력해주세요", text: $store.nickname) + .textInputAutocapitalization(.never) + .pickeTextField(height: 52) + } + + var joinButton: some View { + Button { send(.joinTapped) } label: { + Text("입장하기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(!store.canJoin) + } + + var primaryButton: some View { + Button { send(.findTapped) } label: { + Text("클래스 찾기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(!store.canFindClass) + } +} diff --git a/Projects/Feature/Class/Sources/ClassMember/Reducer/ClassMemberFeature.swift b/Projects/Feature/Class/Sources/ClassMember/Reducer/ClassMemberFeature.swift new file mode 100644 index 00000000..f2ca2014 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassMember/Reducer/ClassMemberFeature.swift @@ -0,0 +1,179 @@ +import ClassDomainInterface +import ComposableArchitecture +import Foundation + +@Reducer +public struct ClassMemberFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public enum ViewState: Equatable { + case unavailable + case noSearchResults + case members([ClassMember]) + } + + public let room: ClassRoom + public var members: [ClassMember] + public var searchText = "" + public var appliedFilter = ClassMemberFilterFeature.State() + @Presents public var filter: ClassMemberFilterFeature.State? + + public init(room: ClassRoom, members: [ClassMember] = []) { + self.room = room + self.members = members + } + + public var visibleMembers: [ClassMember] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let filteredMembers = query.isEmpty + ? members + : members.filter { $0.name.localizedCaseInsensitiveContains(query) } + + switch appliedFilter.sort { + case .name: + return filteredMembers.sorted { + $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + case .comments, .replies, .recommendations: + return filteredMembers + } + } + + public var viewState: ViewState { + guard !members.isEmpty else { return .unavailable } + let visible = visibleMembers + return visible.isEmpty ? .noSearchResults : .members(visible) + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case filter(PresentationAction) + case delegate(DelegateAction) + } + + public enum View { + case backTapped + case filterTapped + case removeTapped(Int) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + 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 .filter(.presented(.applyTapped)): + if let filter = state.filter { + state.appliedFilter = filter + } + state.filter = nil + return .none + case .filter(.presented(.dismissTapped)), .filter(.dismiss): + state.filter = nil + return .none + case .filter: + return .none + case .delegate: + return .none + } + } + .ifLet(\.$filter, action: \.filter) { + ClassMemberFilterFeature() + } + } +} + +#if DEBUG + public extension ClassMemberFeature.State { + static func preview(room: ClassRoom) -> Self { + Self( + room: room, + members: [ + ClassMember(id: 1, name: "김선생", isOwner: true), + ClassMember(id: 2, name: "공은지"), + ClassMember(id: 3, name: "권동현"), + ClassMember(id: 4, name: "김민지"), + ClassMember(id: 5, name: "천다올"), + ClassMember(id: 6, name: "유시영"), + ClassMember(id: 7, name: "주천수"), + ClassMember(id: 8, name: "김예은"), + ClassMember(id: 9, name: "서원지"), + ClassMember(id: 10, name: "현준혁"), + ] + ) + } + } +#endif + +extension ClassMemberFeature { + private func handleViewAction(state: inout State, action: View) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + case .filterTapped: + state.filter = state.appliedFilter + return .none + case let .removeTapped(id): + // Member removal requires a server mutation. Keep this action inert until that API exists. + _ = id + return .none + } + } +} + +@Reducer +public struct ClassMemberFilterFeature { + @ObservableState + public struct State: Equatable { + public var participation: ClassMemberParticipation = .all + public var completion: ClassMemberCompletion = .all + public var attendance: ClassMemberAttendance = .all + public var sort: ClassMemberSort = .name + + public init() {} + } + + public enum Action: Equatable { + case participationSelected(ClassMemberParticipation) + case completionSelected(ClassMemberCompletion) + case attendanceSelected(ClassMemberAttendance) + case sortSelected(ClassMemberSort) + case applyTapped + case dismissTapped + } + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .participationSelected(value): + guard value == .all else { return .none } + state.participation = value + case let .completionSelected(value): + guard value == .all else { return .none } + state.completion = value + case let .attendanceSelected(value): + guard value == .all else { return .none } + state.attendance = value + case let .sortSelected(value): + guard value == .name else { return .none } + state.sort = value + case .applyTapped, .dismissTapped: + break + } + return .none + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassMember/View/ClassMemberView.swift b/Projects/Feature/Class/Sources/ClassMember/View/ClassMemberView.swift new file mode 100644 index 00000000..2a4c7387 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassMember/View/ClassMemberView.swift @@ -0,0 +1,240 @@ +import ClassDomainInterface +import ComposableArchitecture +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassMemberFeature.self) +public struct ClassMemberView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "클래스 멤버") + .foregroundStyle(.gray500) + + searchField + .padding(.horizontal, 16) + .padding(.top, 12) + + if store.room.role == .owner { + Text("멤버 내보내기는 서버 연동 후 제공됩니다.") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 8) + } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + Text(store.members.isEmpty ? "클래스 멤버 \(store.room.memberCount)" : "멤버 \(store.members.count)") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.gray300) + .padding(.bottom, 4) + + switch store.viewState { + case .unavailable: + Text("멤버 목록을 불러올 수 없어요.") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity) + .padding(.top, 72) + case .noSearchResults: + Text("검색 결과가 없어요.") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity) + .padding(.top, 72) + case let .members(members): + ForEach(members) { member in + memberRow(member) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 20) + } + .scrollIndicators(.hidden) + } + .screenBackground() + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .pickeModal( + $store.scope(state: \.filter, action: \.filter), + dimOpacity: 0.28 + ) { filterStore in + filterSheet(store: filterStore) + } + } +} + +#if DEBUG + #Preview("클래스 멤버") { + ClassMemberView(store: Store(initialState: .preview(room: ClassRoom.mocks[0])) { + ClassMemberFeature() + }) + } +#endif + +private extension ClassMemberView { + var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 18)) + .foregroundStyle(.gray300) + TextField("이름을 입력해주세요.", text: $store.searchText) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.gray800) + + Button { send(.filterTapped) } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.gray500) + .frame(width: 32, height: 32) + } + .accessibilityLabel("멤버 필터") + } + .padding(.horizontal, 12) + .frame(height: 44) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + } + + @ViewBuilder + func memberRow(_ member: ClassMember) -> some View { + HStack(spacing: 12) { + PickeAvatarView(imageURL: nil, fallback: member.name, size: 36) + + Text(member.name) + .pretendardFont(family: .Medium, size: 16) + .foregroundStyle(.gray800) + + Spacer(minLength: 0) + + if member.isOwner { + Text("운영자") + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + } + .frame(height: 68) + .overlay(alignment: .bottom) { Rectangle().fill(.beige600).frame(height: 1) } + } + + @ViewBuilder + func filterSheet(store: StoreOf) -> some View { + ZStack(alignment: .bottom) { + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { store.send(.dismissTapped) } + + VStack(alignment: .leading, spacing: 20) { + sheetHandle + filterSection( + title: "참여 상태", + detail: "제공 예정", + options: ClassMemberParticipation.allCases.filter { $0 != .all }, + selected: { store.participation == $0 }, + action: { store.send(.participationSelected($0)) }, + enabled: { _ in false } + ) + filterSection( + title: "미완료", + detail: "제공 예정", + options: ClassMemberCompletion.allCases.filter { $0 != .all }, + selected: { store.completion == $0 }, + action: { store.send(.completionSelected($0)) }, + enabled: { _ in false } + ) + filterSection( + title: "입장 변화", + detail: "제공 예정", + options: ClassMemberAttendance.allCases.filter { $0 != .all }, + selected: { store.attendance == $0 }, + action: { store.send(.attendanceSelected($0)) }, + enabled: { _ in false } + ) + filterSection( + title: "정렬", + detail: "이름순만 제공", + options: ClassMemberSort.allCases, + selected: { store.sort == $0 }, + action: { store.send(.sortSelected($0)) }, + enabled: { $0 == .name } + ) + + Button { store.send(.applyTapped) } label: { + Text("적용하기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + } + .padding(.top, 12) + .padding(.horizontal, 16) + .padding(.bottom, 32) + .frame(maxWidth: .infinity) + .background( + UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26) + .fill(.beige50) + .ignoresSafeArea(edges: .bottom) + ) + } + } + + @ViewBuilder + func filterSection( + title: String, + detail: String, + options: [Option], + selected: @escaping (Option) -> Bool, + action: @escaping (Option) -> Void, + enabled: @escaping (Option) -> Bool + ) -> some View where Option.RawValue == String { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Text(title) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.gray800) + Spacer(minLength: 0) + Text(detail) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.gray300) + } + + HStack(spacing: 8) { + ForEach(options, id: \.self) { option in + Button { action(option) } label: { + Text(option.rawValue) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(selected(option) ? .beige50 : .gray300) + .opacity(enabled(option) ? 1 : 0.5) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 12) + .frame(height: 30) + .background(selected(option) ? .primary500 : .beige200, in: RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + .disabled(!enabled(option)) + } + Spacer(minLength: 0) + } + } + } + + var sheetHandle: some View { + Capsule() + .fill(.gray50) + .frame(width: 40, height: 4) + .frame(maxWidth: .infinity) + .padding(.bottom, 10) + } +} diff --git a/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift b/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift new file mode 100644 index 00000000..da68fc99 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift @@ -0,0 +1,256 @@ +import ClassDomainInterface +import ComposableArchitecture +import Foundation + +@Reducer +public struct ClassOwnerDashboardFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public struct PreviewMetrics: Equatable { + public let commentAuthorCount: Int + public let optionAVoteCount: Int + public let optionBVoteCount: Int + + public init(commentAuthorCount: Int, optionAVoteCount: Int, optionBVoteCount: Int) { + self.commentAuthorCount = commentAuthorCount + self.optionAVoteCount = optionAVoteCount + self.optionBVoteCount = optionBVoteCount + } + } + + public enum MemberViewState: Equatable { + case unavailable + case noSearchResults + case loaded([ClassOwnerMember]) + } + + public enum OpinionViewState: Equatable { + case empty + case loaded([ClassOwnerOpinion]) + } + + public enum TopOpinionViewState: Equatable { + case empty + case unavailable + case loaded([ClassOwnerOpinion]) + } + + public enum MemberSummaryState: Equatable { + case unavailable + case empty + case loaded([ClassOwnerMember]) + } + + public let room: ClassRoom + public var selectedTab: ClassOwnerTab = .home + public var opinionFilter: ClassOwnerOpinionFilter = .popular + public var memberSort: ClassOwnerMemberSort = .name + public var memberFilter: MemberFilter = .all + public var searchText = "" + public var members: [ClassOwnerMember] = [] + public var opinions: [ClassOwnerOpinion] = [] + #if DEBUG + public var usesPreviewContent = false + public var previewMetrics: PreviewMetrics? + #endif + + public init(room: ClassRoom) { + self.room = room + } + + #if DEBUG + public static func preview(room: ClassRoom, tab: ClassOwnerTab = .home) -> Self { + var state = Self(room: room) + state.selectedTab = tab + state.usesPreviewContent = true + state.previewMetrics = PreviewMetrics(commentAuthorCount: 24, optionAVoteCount: 18, optionBVoteCount: 10) + state.members = [ + ClassOwnerMember(id: 1, name: "김민지", participationCount: 8, needsReview: true), + ClassOwnerMember(id: 2, name: "이서준", participationCount: 7), + ClassOwnerMember(id: 3, name: "박지우", participationCount: 6), + ClassOwnerMember(id: 4, name: "최유진", participationCount: 0), + ClassOwnerMember(id: 5, name: "정다은", participationCount: 0), + ClassOwnerMember(id: 6, name: "한도윤", participationCount: 0, needsReview: true), + ] + state.members += (7 ... 32).map { id in + ClassOwnerMember(id: id, name: "희망 \(id)", participationCount: id < 32 ? 1 : 0) + } + state.opinions = [ + ClassOwnerOpinion( + id: 1, + author: "김민지", + text: "피해자 보호를 위한 기준도 함께 필요하지 않을까요?", + replyCount: 23, + recommendationCount: 1340, + createdAt: Date(timeIntervalSince1970: 1_780_000_300) + ), + ClassOwnerOpinion( + id: 2, + author: "이서준", + text: "제도화가 선택을 의무로 바꾸지 않도록 기준이 필요해요.", + replyCount: 12, + recommendationCount: 840, + createdAt: Date(timeIntervalSince1970: 1_780_000_200) + ), + ClassOwnerOpinion( + id: 3, + author: "박지우", + text: "교육과 보호도 함께 고려해야 한다고 생각합니다.", + replyCount: 4, + recommendationCount: 510, + createdAt: Date(timeIntervalSince1970: 1_780_000_100) + ), + ] + return state + } + #endif + + public var searchedMembers: [ClassOwnerMember] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return query.isEmpty ? members : members.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + + public var visibleMembers: [ClassOwnerMember] { + let searched = searchedMembers + let matching: [ClassOwnerMember] = switch memberFilter { + case .all: searched + case .participated: searched.filter { $0.participationCount > 0 } + case .notParticipated: searched.filter { $0.participationCount == 0 } + } + switch memberSort { + case .name: + return matching.sorted { $0.name < $1.name } + case .participation: + return matching.sorted { $0.participationCount > $1.participationCount } + case .needsReview: + return matching.sorted { $0.needsReview && !$1.needsReview } + } + } + + public var visibleOpinions: [ClassOwnerOpinion] { + guard let activeOpinionFilter else { return opinions } + switch activeOpinionFilter { + case .latest: + return opinions.sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } + case .popular: + return opinions.sorted { ($0.recommendationCount ?? 0) > ($1.recommendationCount ?? 0) } + } + } + + public var activeOpinionFilter: ClassOwnerOpinionFilter? { + canSortOpinions(opinionFilter) ? opinionFilter : nil + } + + public var topOpinionViewState: TopOpinionViewState { + guard !opinions.isEmpty else { return .empty } + guard canSortOpinions(.popular) else { return .unavailable } + let top = Array(opinions.sorted { ($0.recommendationCount ?? 0) > ($1.recommendationCount ?? 0) }.prefix(2)) + return .loaded(top) + } + + public var memberViewState: MemberViewState { + guard !members.isEmpty else { return .unavailable } + let visible = visibleMembers + return visible.isEmpty ? .noSearchResults : .loaded(visible) + } + + public var needsReviewViewState: MemberSummaryState { + guard !members.isEmpty else { return .unavailable } + let matching = members.filter(\.needsReview) + return matching.isEmpty ? .empty : .loaded(matching) + } + + public var highParticipationViewState: MemberSummaryState { + guard !members.isEmpty else { return .unavailable } + let matching = Array(members.filter { $0.participationCount > 0 } + .sorted { $0.participationCount > $1.participationCount } + .prefix(3)) + return matching.isEmpty ? .empty : .loaded(matching) + } + + public var opinionViewState: OpinionViewState { + let visible = visibleOpinions + return visible.isEmpty ? .empty : .loaded(visible) + } + + public func canSortOpinions(_ filter: ClassOwnerOpinionFilter) -> Bool { + guard !opinions.isEmpty else { return false } + switch filter { + case .popular: return opinions.allSatisfy { $0.recommendationCount != nil } + case .latest: return opinions.allSatisfy { $0.createdAt != nil } + } + } + + public var availableOpinionFilters: Set { + Set(ClassOwnerOpinionFilter.allCases.filter(canSortOpinions)) + } + } + + public enum MemberFilter: CaseIterable, Hashable, Sendable { + case all + case participated + case notParticipated + } + + public enum Action: ViewAction { + case view(View) + case delegate(DelegateAction) + } + + public enum View: Equatable { + case backTapped + case tabSelected(ClassOwnerTab) + case opinionFilterSelected(ClassOwnerOpinionFilter) + case memberSortSelected(ClassOwnerMemberSort) + case memberFilterSelected(MemberFilter) + case searchTextChanged(String) + case memberTapped(Int) + case opinionTapped(Int) + case feedbackTapped(Int) + } + + public enum DelegateAction: Equatable { + case dismiss + case openMemberDetail(ClassOwnerMember) + case openReplyDetail(ClassOwnerOpinion) + case openFeedback(ClassOwnerMember) + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .view(viewAction): + switch viewAction { + case .backTapped: + return .send(.delegate(.dismiss)) + case let .tabSelected(tab): + state.selectedTab = tab + case let .opinionFilterSelected(filter): + guard state.canSortOpinions(filter) else { return .none } + state.opinionFilter = filter + case let .memberSortSelected(sort): + state.memberSort = sort + case let .memberFilterSelected(filter): + guard !state.members.isEmpty || filter == .all else { return .none } + state.memberFilter = filter + case let .searchTextChanged(text): + state.searchText = text + case let .memberTapped(id): + guard let member = state.members.first(where: { $0.id == id }) else { return .none } + return .send(.delegate(.openMemberDetail(member))) + case let .opinionTapped(id): + guard let opinion = state.opinions.first(where: { $0.id == id }) else { return .none } + return .send(.delegate(.openReplyDetail(opinion))) + case let .feedbackTapped(id): + guard let member = state.members.first(where: { $0.id == id }) else { return .none } + return .send(.delegate(.openFeedback(member))) + } + return .none + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift b/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift new file mode 100644 index 00000000..1f389da7 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift @@ -0,0 +1,475 @@ +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import PickeDesignKit +import SwiftUI + +private enum MemberRowAction: String { + case detail = "상세" + case feedback = "피드백" +} + +@ViewAction(for: ClassOwnerDashboardFeature.self) +public struct ClassOwnerDashboardView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: store.room.name) + .foregroundStyle(.gray800) + + titleHeader() + dashboardTabs() + TabView(selection: Binding( + get: { store.selectedTab }, + set: { send(.tabSelected($0)) } + )) { + ScrollView { homePage().padding(16) }.tag(ClassOwnerTab.home) + ScrollView { membersPage().padding(16) }.tag(ClassOwnerTab.members) + ScrollView { opinionsPage().padding(16) }.tag(ClassOwnerTab.opinions) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + .screenBackground() + .toolbar(.hidden, for: .navigationBar) + .toolbar(.visible, for: .tabBar) + } +} + +private extension ClassOwnerDashboardView { + @ViewBuilder + func titleHeader() -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text(store.room.status == .open ? "진행 중" : "종료") + .foregroundStyle(.primary500) + Text("#\(store.room.battle.category.title)") + .foregroundStyle(.gray500) + } + .pretendardFont(.medium13) + Text(store.room.battle.title) + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + Text("\(Self.deadlineFormatter.string(from: store.room.deadline))까지") + .pretendardFont(.medium13) + .foregroundStyle(.gray500) + } + .padding(.horizontal, 16) + .frame(height: 118, alignment: .center) + } + + static let deadlineFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy. MM. dd. EEEE HH:mm" + return formatter + }() + + static let opinionDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yy.MM.dd. HH:mm" + return formatter + }() + + @ViewBuilder + func dashboardTabs() -> some View { + HStack(spacing: 0) { + ForEach(ClassOwnerTab.allCases, id: \.self) { tab in + Button { send(.tabSelected(tab)) } label: { + Text(tab == .home ? "요약" : tab.rawValue) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(store.selectedTab == tab ? .primary500 : .gray300) + .frame(maxWidth: .infinity) + .frame(height: 36) + .overlay(alignment: .bottom) { + Rectangle() + .fill(store.selectedTab == tab ? .primary500 : .gray100) + .frame(height: store.selectedTab == tab ? 2 : 1) + } + } + .buttonStyle(.plain) + } + } + } + + @ViewBuilder + func homePage() -> some View { + VStack(alignment: .leading, spacing: 24) { + dashboardSection("클래스 참여 현황") { + if let metrics = previewMetrics { + let total = store.members.count + let participated = store.members.filter { $0.participationCount > 0 }.count + VStack(spacing: 12) { + participationRow( + "배틀 참여율", + value: percentage(participated, of: total), + detail: "참여한 멤버", + count: "\(participated) / \(total)명", + progress: ratio(participated, of: total) + ) + participationRow( + "댓글 작성율", + value: percentage(metrics.commentAuthorCount, of: total), + detail: "댓글을 남긴 멤버", + count: "\(metrics.commentAuthorCount) / \(total)명", + progress: ratio(metrics.commentAuthorCount, of: total) + ) + } + .frame(height: 152) + .padding(.horizontal, 17) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + } else { + unavailableContent("참여 현황을 불러올 수 없어요") + } + } + dashboardSection("입장 분포") { + if let metrics = previewMetrics { + let total = metrics.optionAVoteCount + metrics.optionBVoteCount + HStack(spacing: 20) { + ZStack { + Circle().stroke(.beige600, lineWidth: 11) + Circle() + .trim(from: 0, to: ratio(metrics.optionAVoteCount, of: total)) + .stroke(.primary500, style: StrokeStyle(lineWidth: 11, lineCap: .round)) + .rotationEffect(.degrees(-90)) + VStack(spacing: 0) { + Text("\(total)명").pretendardFont(.semiBold24) + Text("총 참여").pretendardFont(.regular10) + } + } + .frame(width: 92, height: 92) + VStack(alignment: .leading, spacing: 6) { + Text( + "\(store.room.battle.optionATitle) \(metrics.optionAVoteCount)명 · \(roundedPercentage(metrics.optionAVoteCount, of: total))" + ) + Text( + "\(store.room.battle.optionBTitle) \(metrics.optionBVoteCount)명 · \(roundedPercentage(metrics.optionBVoteCount, of: total))" + ) + } + .pretendardFont(.regular13) + } + .foregroundStyle(.gray700) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: 134) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + } else { + unavailableContent("입장 분포를 불러올 수 없어요") + } + } + dashboardSection("확인이 필요한 멤버") { + switch store.needsReviewViewState { + case .unavailable: + unavailableContent("멤버 활동 정보가 없어요") + case .empty: + unavailableContent("확인이 필요한 멤버가 없어요") + case let .loaded(members): + ForEach(members) { memberRow($0, action: .feedback) } + } + } + dashboardSection("참여도가 높은 멤버") { + switch store.highParticipationViewState { + case .unavailable: + unavailableContent("멤버 활동 정보가 없어요") + case .empty: + unavailableContent("아직 참여한 멤버가 없어요") + case let .loaded(members): + ForEach(members) { member in + memberRow(member, action: .detail) + } + } + } + dashboardSection("주요 의견") { + switch store.topOpinionViewState { + case .empty: + unavailableContent("등록된 의견이 없어요") + case .unavailable: + unavailableContent("주요 의견을 집계할 수 없어요") + case let .loaded(opinions): + ForEach(opinions) { opinion in opinionRow(opinion) } + } + } + } + } + + @ViewBuilder + func membersPage() -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.gray500) + TextField("이름을 입력해주세요.", text: Binding( + get: { store.searchText }, + set: { send(.searchTextChanged($0)) } + )) + .pretendardFont(.regular13) + } + .padding(.horizontal, 12) + .frame(height: 44) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ClassOwnerDashboardFeature.MemberFilter.allCases, id: \.self) { filter in + Button { send(.memberFilterSelected(filter)) } label: { + Text(memberFilterTitle(filter)) + .pickeSortChip(isSelected: store.memberFilter == filter) + } + .buttonStyle(.plain) + .disabled(store.members.isEmpty && filter != .all) + .opacity(store.members.isEmpty && filter != .all ? 0.5 : 1) + } + } + } + Text(store.members.isEmpty ? "클래스 멤버 \(store.room.memberCount)" : "멤버 \(store.members.count)") + .pretendardFont(.semiBold15).foregroundStyle(.gray800) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ClassOwnerMemberSort.allCases, id: \.self) { sort in + Button { send(.memberSortSelected(sort)) } label: { + Text(sort.rawValue).pickeSortChip(isSelected: store.memberSort == sort) + } + .buttonStyle(.plain) + } + } + } + switch store.memberViewState { + case .unavailable: + unavailableContent("멤버 활동 정보가 없어요") + case .noSearchResults: + unavailableContent("검색 결과가 없어요") + case let .loaded(members): + ForEach(members) { member in + memberRow(member, action: member.needsReview ? .feedback : .detail) + } + } + } + } + + @ViewBuilder + func opinionsPage() -> some View { + VStack(alignment: .leading, spacing: 12) { + opinionFilters() + switch store.opinionViewState { + case .empty: + unavailableContent("등록된 의견이 없어요") + case let .loaded(opinions): + ForEach(opinions) { opinion in opinionRow(opinion) } + } + } + } + + @ViewBuilder + func opinionFilters() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ClassOwnerOpinionFilter.allCases, id: \.self) { filter in + Button { send(.opinionFilterSelected(filter)) } label: { + Text(filter.rawValue).pickeSortChip(isSelected: store.activeOpinionFilter == filter) + } + .buttonStyle(.plain) + .disabled(!store.availableOpinionFilters.contains(filter)) + .opacity(store.availableOpinionFilters.contains(filter) ? 1 : 0.5) + } + } + } + } + + @ViewBuilder + func dashboardSection( + _ title: String, + @ViewBuilder content: () -> some View + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title).pretendardFont(.semiBold15).foregroundStyle(.gray800) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + var usesPreviewContent: Bool { + #if DEBUG + store.usesPreviewContent + #else + false + #endif + } + + var previewMetrics: ClassOwnerDashboardFeature.State.PreviewMetrics? { + #if DEBUG + store.previewMetrics + #else + nil + #endif + } + + func ratio(_ count: Int, of total: Int) -> CGFloat { + guard total > 0 else { return 0 } + return CGFloat(count) / CGFloat(total) + } + + func percentage(_ count: Int, of total: Int) -> String { + guard total > 0 else { return "0%" } + return "\((Double(count) / Double(total) * 100).formatted(.number.precision(.fractionLength(0 ... 1))))%" + } + + func roundedPercentage(_ count: Int, of total: Int) -> String { + guard total > 0 else { return "0%" } + return "\(Int((Double(count) / Double(total) * 100).rounded()))%" + } + + func memberFilterTitle(_ filter: ClassOwnerDashboardFeature.MemberFilter) -> String { + switch filter { + case .all: return "전체" + case .participated: + return store.members.isEmpty + ? "참여 완료" + : "참여 완료 \(store.searchedMembers.filter { $0.participationCount > 0 }.count)" + case .notParticipated: + return store.members.isEmpty + ? "미참여" + : "미참여 \(store.searchedMembers.filter { $0.participationCount == 0 }.count)" + } + } + + func unavailableContent(_ message: String) -> some View { + Text(message) + .pretendardFont(.regular13) + .foregroundStyle(.gray500) + .frame(maxWidth: .infinity) + .frame(minHeight: 88) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + } + + func participationRow(_ title: String, value: String, detail: String, count: String, progress: CGFloat) -> some View { + VStack(spacing: 4) { + HStack { + Text(title).pretendardFont(.medium13) + Spacer() + Text(value).pretendardFont(.semiBold15) + } + HStack { + Text(detail) + Spacer() + Text(count) + } + .pretendardFont(.regular10) + .foregroundStyle(.gray500) + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(.beige600) + Capsule().fill(.primary500).frame(width: geometry.size.width * progress) + } + } + .frame(height: 6) + } + .foregroundStyle(.gray800) + .frame(height: 51) + } + + @ViewBuilder + func memberRow(_ member: ClassOwnerMember, action: MemberRowAction) -> some View { + HStack(spacing: 10) { + Button { send(.memberTapped(member.id)) } label: { + HStack(spacing: 10) { + Text(String(member.name.prefix(1))).pickeAvatarFallback(size: 32).pickeAvatar(size: 32) + VStack(alignment: .leading, spacing: 2) { + Text(member.name).pretendardFont(.medium13).foregroundStyle(.gray700) + Text("배틀 참여 \(member.participationCount)회").pretendardFont(.regular10).foregroundStyle(.gray300) + } + Spacer() + } + } + .buttonStyle(.plain) + + Button { + send(action == .feedback ? .feedbackTapped(member.id) : .memberTapped(member.id)) + } label: { + Text(action.rawValue).pretendardFont(family: .Bold, size: 12).foregroundStyle(.primary500) + } + .buttonStyle(.plain) + } + .padding(.vertical, 7) + } + + @ViewBuilder + func opinionRow(_ opinion: ClassOwnerOpinion) -> some View { + Button { send(.opinionTapped(opinion.id)) } label: { + VStack(alignment: .leading, spacing: 7) { + HStack { + Text(String(opinion.author.prefix(1))).pickeAvatarFallback(size: 36).pickeAvatar(size: 36) + VStack(alignment: .leading, spacing: 2) { + Text(opinion.author).pretendardFont(.medium13).foregroundStyle(.gray500) + if usesPreviewContent, let createdAt = opinion.createdAt { + Text(Self.opinionDateFormatter.string(from: createdAt)) + .pretendardFont(.regular10) + .foregroundStyle(.gray300) + } + } + Spacer() + if usesPreviewContent { + Text(store.room.battle.optionATitle) + .pretendardFont(.regular10) + .foregroundStyle(.primary500) + .padding(.horizontal, 4) + .frame(height: 21) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + if opinion.isReported { + Text("신고").pretendardFont(.regular10).foregroundStyle(.errorDefault) + } + } + Text(opinion.text) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + .multilineTextAlignment(.leading) + .lineLimit(2) + Text("자세히 보기") + .pretendardFont(.regular10) + .foregroundStyle(.gray500) + HStack(spacing: 16) { + if let recommendationCount = opinion.recommendationCount { + Text("추천 \(recommendationCount.formatted())") + } + Text("대댓글 \(opinion.replyCount)") + } + .pretendardFont(.regular10) + .foregroundStyle(.gray300) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + }.buttonStyle(.plain) + } +} + +#if DEBUG + #Preview("운영자 대시보드 · 요약") { + ClassOwnerDashboardView(store: Store(initialState: .preview(room: ClassRoom.mocks[0])) { + ClassOwnerDashboardFeature() + }) + } + + #Preview("운영자 대시보드 · 멤버") { + ClassOwnerDashboardView(store: Store(initialState: .preview(room: ClassRoom.mocks[0], tab: .members)) { + ClassOwnerDashboardFeature() + }) + } + + #Preview("운영자 대시보드 · 의견") { + ClassOwnerDashboardView(store: Store(initialState: .preview(room: ClassRoom.mocks[0], tab: .opinions)) { + ClassOwnerDashboardFeature() + }) + } +#endif diff --git a/Projects/Feature/Class/Sources/ClassOwnerDetail/Reducer/ClassFeedbackComposeFeature.swift b/Projects/Feature/Class/Sources/ClassOwnerDetail/Reducer/ClassFeedbackComposeFeature.swift new file mode 100644 index 00000000..9eb068c2 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassOwnerDetail/Reducer/ClassFeedbackComposeFeature.swift @@ -0,0 +1,59 @@ +import ClassDomainInterface +import ComposableArchitecture +import Foundation + +@Reducer +public struct ClassFeedbackComposeFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public let room: ClassRoom + public let memberName: String + public var feedback = "" + public var showUnavailableAlert = false + + public init(room: ClassRoom, memberName: String) { + self.room = room + self.memberName = memberName + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View: Equatable { + case backTapped + case cancelTapped + case submitTapped + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + case submitted(String) + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + case .view(.backTapped), .view(.cancelTapped): + return .send(.delegate(.dismiss)) + case .view(.submitTapped): + let feedback = state.feedback.trimmingCharacters(in: .whitespacesAndNewlines) + guard !feedback.isEmpty else { return .none } + state.showUnavailableAlert = true + return .none + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassOwnerDetail/Reducer/ClassMemberDetailFeature.swift b/Projects/Feature/Class/Sources/ClassOwnerDetail/Reducer/ClassMemberDetailFeature.swift new file mode 100644 index 00000000..0ee8eb1e --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassOwnerDetail/Reducer/ClassMemberDetailFeature.swift @@ -0,0 +1,100 @@ +import ClassDomainInterface +import ComposableArchitecture + +@Reducer +public struct ClassMemberDetailFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public let room: ClassRoom + public let member: ClassOwnerMember + public let activity: Activity? + + public struct Activity: Equatable, Sendable { + public let initialStance: String + public let finalStance: String + public let commentCount: Int + public let replyCount: Int + public let receivedLikeCount: Int + public let comment: String? + public let commentOpinion: ClassOwnerOpinion? + public let reply: String? + public let feedback: String? + + public var changedStance: Bool { + initialStance != finalStance + } + + public init( + initialStance: String, + finalStance: String, + commentCount: Int, + replyCount: Int, + receivedLikeCount: Int, + comment: String? = nil, + commentOpinion: ClassOwnerOpinion? = nil, + reply: String? = nil, + feedback: String? = nil + ) { + self.initialStance = initialStance + self.finalStance = finalStance + self.commentCount = commentCount + self.replyCount = replyCount + self.receivedLikeCount = receivedLikeCount + self.comment = comment + self.commentOpinion = commentOpinion + self.reply = reply + self.feedback = feedback + } + } + + public init( + room: ClassRoom, + member: ClassOwnerMember, + activity: Activity? = nil + ) { + self.room = room + self.member = member + self.activity = activity + } + } + + public enum Action: ViewAction { + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View: Equatable { + case backTapped + case commentTapped + case feedbackTapped + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + case commentSelected(ClassOwnerOpinion) + case feedbackSelected + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .view(viewAction): + switch viewAction { + case .backTapped: + return .send(.delegate(.dismiss)) + case .commentTapped: + guard let opinion = state.activity?.commentOpinion else { return .none } + return .send(.delegate(.commentSelected(opinion))) + case .feedbackTapped: + return .send(.delegate(.feedbackSelected)) + } + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassOwnerDetail/Reducer/ClassReplyDetailFeature.swift b/Projects/Feature/Class/Sources/ClassOwnerDetail/Reducer/ClassReplyDetailFeature.swift new file mode 100644 index 00000000..9a6b35f9 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassOwnerDetail/Reducer/ClassReplyDetailFeature.swift @@ -0,0 +1,71 @@ +import ClassDomainInterface +import ComposableArchitecture + +@Reducer +public struct ClassReplyDetailFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public enum ViewState: Equatable { + case unavailable + case empty + case loaded([Reply]) + } + + public let room: ClassRoom + public let opinion: ClassOwnerOpinion? + public let replies: [Reply] + + public struct Reply: Equatable, Identifiable, Sendable { + public let id: Int + public let author: String + public let body: String + public let date: String + + public init(id: Int, author: String, body: String, date: String) { + self.id = id + self.author = author + self.body = body + self.date = date + } + } + + public init(room: ClassRoom, opinion: ClassOwnerOpinion? = nil, replies: [Reply] = []) { + self.room = room + self.opinion = opinion + self.replies = replies + } + + public var viewState: ViewState { + guard replies.isEmpty else { return .loaded(replies) } + return opinion?.replyCount == 0 ? .empty : .unavailable + } + } + + public enum Action: ViewAction { + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View: Equatable { + case backTapped + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + } + + public var body: some Reducer { + Reduce { _, action in + switch action { + case .view(.backTapped): + return .send(.delegate(.dismiss)) + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassOwnerDetail/View/ClassFeedbackComposeView.swift b/Projects/Feature/Class/Sources/ClassOwnerDetail/View/ClassFeedbackComposeView.swift new file mode 100644 index 00000000..c446f901 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassOwnerDetail/View/ClassFeedbackComposeView.swift @@ -0,0 +1,91 @@ +import ComposableArchitecture +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassFeedbackComposeFeature.self) +public struct ClassFeedbackComposeView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: store.room.name) + .foregroundStyle(.gray800) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + PickeAvatarView(imageURL: nil, fallback: store.memberName, size: 40) + VStack(alignment: .leading, spacing: 4) { + Text(store.memberName) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.gray800) + Text("운영자 피드백") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + } + } + feedbackEditor + HStack(spacing: 8) { + Button { send(.cancelTapped) } label: { + Text("취소").frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .ctaButtonStyle(.secondary, size: .large, height: 52) + + Button { send(.submitTapped) } label: { + Text("등록").frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(store.feedback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(16) + } + .scrollIndicators(.hidden) + } + .screenBackground() + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .alert("전송 준비 중", isPresented: $store.showUnavailableAlert) { + Button("확인", role: .cancel) {} + } message: { + Text("서버 연동 전이라 피드백을 전송할 수 없습니다.") + } + } +} + +private extension ClassFeedbackComposeView { + var feedbackEditor: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("운영자 피드백") + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.gray800) + Spacer() + } + TextEditor(text: $store.feedback) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + .scrollContentBackground(.hidden) + .padding(8) + .frame(height: 280) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(alignment: .topLeading) { + if store.feedback.isEmpty { + Text("학생에게 전할 피드백을 작성해주세요.") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + .padding(.horizontal, 12) + .padding(.top, 16) + .allowsHitTesting(false) + } + } + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassOwnerDetail/View/ClassMemberDetailView.swift b/Projects/Feature/Class/Sources/ClassOwnerDetail/View/ClassMemberDetailView.swift new file mode 100644 index 00000000..2ff7cced --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassOwnerDetail/View/ClassMemberDetailView.swift @@ -0,0 +1,215 @@ +import ClassDomainInterface +import ComposableArchitecture +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassMemberDetailFeature.self) +public struct ClassMemberDetailView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: store.room.name) + .foregroundStyle(.gray800) + ScrollView { + VStack(alignment: .leading, spacing: 24) { + memberHeader + summarySection + commentSection + replySection + feedbackSection + } + .padding(16) + } + .scrollIndicators(.hidden) + } + .screenBackground() + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + } +} + +private extension ClassMemberDetailView { + var memberHeader: some View { + HStack(spacing: 12) { + PickeAvatarView(imageURL: nil, fallback: store.member.name, size: 40) + VStack(alignment: .leading, spacing: 4) { + Text(store.member.name) + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + if let activity = store.activity { + Text("댓글 \(activity.commentCount) · 대댓글 \(activity.replyCount)") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + } + Spacer() + if store.member.participationCount > 0 { + Text("참여 완료").pickeBadge(.filled, size: .tag) + } + } + } + + var summarySection: some View { + section("참여 요약") { + if let activity = store.activity { + HStack(spacing: 8) { + stance(activity.initialStance, label: "사전 선택") + VStack(spacing: 4) { + Image(systemName: activity.changedStance ? "arrow.right" : "equal") + Text(activity.changedStance ? "입장 변경" : "입장 유지") + } + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.gray300) + stance(activity.finalStance, label: "사후 선택") + } + HStack(spacing: 0) { + metric(activity.commentCount, label: "댓글") + Rectangle().fill(.beige600).frame(width: 1, height: 36) + metric(activity.replyCount, label: "대댓글") + Rectangle().fill(.beige600).frame(width: 1, height: 36) + metric(activity.receivedLikeCount, label: "받은 좋아요") + } + .padding(.vertical, 12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + } else { + emptyCard("참여 기록이 아직 없어요.") + } + } + } + + var commentSection: some View { + section("남긴 댓글") { + if let comment = store.activity?.comment { + if store.activity?.commentOpinion != nil { + Button { send(.commentTapped) } label: { activityCard(comment, showsChevron: true) } + .buttonStyle(.plain) + } else { + activityCard(comment) + } + } else { + emptyCard("남긴 댓글이 없어요.") + } + } + } + + var replySection: some View { + section("남긴 대댓글") { + if let reply = store.activity?.reply { + activityCard(reply) + } else { + emptyCard("남긴 대댓글이 없어요.") + } + } + } + + var feedbackSection: some View { + section("운영자 피드백") { + VStack(alignment: .leading, spacing: 12) { + if let feedback = store.activity?.feedback { + Text(feedback) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + } else { + Text("아직 작성된 피드백이 없어요.") + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + Text("참여 기록과 의견을 확인한 뒤 개별 피드백을 남겨주세요.") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + Button { send(.feedbackTapped) } label: { + Text(store.activity?.feedback == nil ? "피드백 작성하기" : "피드백 수정하기") + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .ctaButtonStyle(.primary, size: .medium, height: 42) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + } + } + + func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title).pretendardFont(.semiBold15).foregroundStyle(.gray800) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + func stance(_ value: String, label: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(label).pretendardFont(.regular13).foregroundStyle(.gray300) + Text(value).pretendardFont(.semiBold15).foregroundStyle(.gray800) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + } + + func metric(_ value: Int, label: String) -> some View { + VStack(spacing: 4) { + Text("\(value)").pretendardFont(.semiBold15).foregroundStyle(.gray800) + Text(label).pretendardFont(.regular13).foregroundStyle(.gray300) + } + .frame(maxWidth: .infinity) + } + + func activityCard(_ text: String, showsChevron: Bool = false) -> some View { + HStack(spacing: 8) { + Text(text) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + if showsChevron { + Image(systemName: "chevron.right").foregroundStyle(.gray300) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + } + + func emptyCard(_ text: String) -> some View { + Text(text) + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + } +} + +#if DEBUG + #Preview("운영자 멤버 상세") { + ClassMemberDetailView(store: Store(initialState: ClassMemberDetailFeature.State( + room: ClassRoom.mocks[0], + member: ClassOwnerMember(id: 1, name: "공은지", participationCount: 1), + activity: .init( + initialStance: "낮춰야 한다", + finalStance: "교정이 우선이다", + commentCount: 2, + replyCount: 3, + receivedLikeCount: 12, + comment: "제도화가 무서운 건, 사회적 압력이 선택을 의무로 바꿀 수 있다는 거예요.", + commentOpinion: ClassOwnerOpinion( + id: 101, + author: "공은지", + text: "제도화가 무서운 건, 사회적 압력이 선택을 의무로 바꿀 수 있다는 거예요.", + replyCount: 2 + ), + reply: "다른 의견을 읽고 생각을 바꿨어요." + ) + )) { + ClassMemberDetailFeature() + }) + } +#endif diff --git a/Projects/Feature/Class/Sources/ClassOwnerDetail/View/ClassReplyDetailView.swift b/Projects/Feature/Class/Sources/ClassOwnerDetail/View/ClassReplyDetailView.swift new file mode 100644 index 00000000..ef7d7e3f --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassOwnerDetail/View/ClassReplyDetailView.swift @@ -0,0 +1,94 @@ +import ComposableArchitecture +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassReplyDetailFeature.self) +public struct ClassReplyDetailView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: store.room.name) + .foregroundStyle(.gray800) + ScrollView { + VStack(spacing: 12) { + if let opinion = store.opinion { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + PickeAvatarView(imageURL: nil, fallback: opinion.author, size: 36) + Text(opinion.author) + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + Spacer() + } + Text(opinion.text) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + .frame(maxWidth: .infinity, alignment: .leading) + Label("\(opinion.replyCount)", systemImage: "bubble") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + } + switch store.viewState { + case .unavailable: + Text("대댓글을 불러올 수 없어요.") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + case .empty: + Text("아직 대댓글이 없어요.") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + case let .loaded(replies): + ForEach(replies) { reply in + replyCard(reply) + } + } + } + .padding(16) + } + .scrollIndicators(.hidden) + } + .screenBackground() + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + } +} + +private extension ClassReplyDetailView { + @ViewBuilder + func replyCard(_ reply: ClassReplyDetailFeature.State.Reply) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + PickeAvatarView(imageURL: nil, fallback: reply.author, size: 32) + Text(reply.author) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.gray800) + Spacer() + Text(reply.date) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.gray300) + } + Text(reply.body) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + .frame(maxWidth: .infinity, alignment: .leading) + } + .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/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift new file mode 100644 index 00000000..7fb90743 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift @@ -0,0 +1,181 @@ +// +// ClassRecommendFeature.swift +// Class +// + +import ClassDomainInterface +import ComposableArchitecture +import Foundation + +@Reducer +public struct ClassRecommendFeature { + @Dependency(\.classUseCase) private var classUseCase + + public init() {} + + @ObservableState + public struct State: Equatable { + public enum ViewState: Equatable { + case loading + case error + case empty + case loaded + } + + public var filter: ClassTopicFilter + public var battles: [ClassBattleSummary] = [] + public var selectedBattleId: Int? + public var isLoading = false + public var loadFailed = false + + public init(filter: ClassTopicFilter) { + self.filter = filter + } + + public var conditionTitles: [String] { + [filter.level.title, filter.category?.title].compactMap(\.self) + } + + public var resultTitle: String { + "추천 결과 \(battles.count)개" + } + + public var selectedBattle: ClassBattleSummary? { + battles.first { $0.id == selectedBattleId } + } + + public var canSelect: Bool { + selectedBattle != nil + } + + public var viewState: ViewState { + if loadFailed && battles.isEmpty { + return .error + } + if isLoading && battles.isEmpty { + return .loading + } + return battles.isEmpty ? .empty : .loaded + } + } + + public enum Action: ViewAction { + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case retryTapped + case backTapped + case editConditionTapped + case battleTapped(Int) + case previewTapped(Int) + case selectTapped + } + + public enum AsyncAction: Equatable { + case fetch(ClassTopicFilter) + } + + public enum InnerAction: Equatable { + case battles(Result<[ClassBattleSummary], ClassError>) + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + case select(ClassBattleSummary) + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + 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 ClassRecommendFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.battles.isEmpty else { return .none } + return .send(.async(.fetch(state.filter))) + + case .retryTapped: + return .send(.async(.fetch(state.filter))) + + case .backTapped, .editConditionTapped: + return .send(.delegate(.dismiss)) + + case let .battleTapped(id): + state.selectedBattleId = state.selectedBattleId == id ? nil : id + return .none + + case .previewTapped: + return .none + + case .selectTapped: + guard let battle = state.selectedBattle else { return .none } + return .send(.delegate(.select(battle))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetch(filter): + state.isLoading = true + state.loadFailed = false + return .run { [classUseCase] send in + let result = await Result { + try await classUseCase.fetchRecommendedBattles(filter: filter) + } + .mapError(ClassError.from) + await send(.inner(.battles(result))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .battles(result): + state.isLoading = false + switch result { + case let .success(battles): + state.battles = battles + case .failure: + state.loadFailed = true + } + return .none + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassRecommend/View/ClassRecommendView.swift b/Projects/Feature/Class/Sources/ClassRecommend/View/ClassRecommendView.swift new file mode 100644 index 00000000..62294b61 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassRecommend/View/ClassRecommendView.swift @@ -0,0 +1,152 @@ +// +// ClassRecommendView.swift +// Class +// + +import ClassDomainInterface +import ComposableArchitecture +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassRecommendFeature.self) +public struct ClassRecommendView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar( + onBack: { send(.backTapped) }, + centerTitle: "추천 배틀" + ) + .foregroundStyle(.gray500) + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + introSection() + resultSection() + } + .padding(.bottom, 148) + } + .scrollIndicators(.hidden) + } + .overlay(alignment: .bottom) { + bottomSection() + } + .screenBackground() + .hidesSystemBars() + .onAppear { send(.onAppear) } + } +} + +private extension ClassRecommendView { + @ViewBuilder + func introSection() -> some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("수업에 어울리는\n배틀을 찾았어요") + .pretendardFont(.semiBold24) + .lineSpacing(2.4) + .foregroundStyle(.gray800) + + Text("미리 듣고 클래스에 사용할 배틀을 골라보세요.") + .pretendardFont(.medium15) + .foregroundStyle(.gray300) + } + + conditionRow() + } + .padding(16) + } + + @ViewBuilder + func conditionRow() -> some View { + HStack(spacing: 8) { + Text("선택한 조건") + .pretendardFont(.medium13) + .foregroundStyle(.gray300) + + HStack(spacing: 6) { + ForEach(store.conditionTitles, id: \.self) { + Text($0).pickePill() + } + } + + Spacer(minLength: 0) + + Button { + send(.editConditionTapped) + } label: { + Text("조건 수정") + .pretendardFont(.semiBold13) + .foregroundStyle(.primary500) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + func resultSection() -> some View { + Group { + switch store.viewState { + case .error: + PickeRetryErrorView(message: "추천 배틀을 불러오지 못했어요") { send(.retryTapped) } + .frame(maxWidth: .infinity, minHeight: 160) + case .loading: + ClassRecommendSkeletonView() + case .empty, .loaded: + resultList() + } + } + .padding([.horizontal, .top], 16) + } + + @ViewBuilder + func resultList() -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(store.resultTitle) + .pretendardFont(.headingSmall) + .foregroundStyle(.gray800) + + ForEach(store.battles) { battle in + ClassBattleListCard( + battle: battle, + isSelected: store.selectedBattleId == battle.id, + onPreview: { send(.previewTapped(battle.id)) } + ) + .onTapGesture { send(.battleTapped(battle.id)) } + } + } + } + + @ViewBuilder + func bottomSection() -> some View { + VStack(spacing: 0) { + LinearGradient( + colors: [.beige200.opacity(0), .beige200], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 70) + .allowsHitTesting(false) + + selectButton + .padding(.horizontal, 12) + .background(.beige200) + } + } + + var selectButton: some View { + Button { + send(.selectTapped) + } label: { + Text("배틀 선택하기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(!store.canSelect) + } +} diff --git a/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassBattleListCard.swift b/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassBattleListCard.swift new file mode 100644 index 00000000..071f5384 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassBattleListCard.swift @@ -0,0 +1,142 @@ +// +// ClassBattleListCard.swift +// Class +// + +import ClassDomainInterface +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +/// 추천 배틀 목록 카드. 선택 시 primary500 테두리 + 체크박스가 채워진다. +struct ClassBattleListCard: View { + let battle: ClassBattleSummary + let isSelected: Bool + let onPreview: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { + metaRow() + titleSection() + } + versusRow() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .pickeCard(.beige50, border: isSelected ? .primary500 : .beige600) + .contentShape(Rectangle()) + } +} + +private extension ClassBattleListCard { + @ViewBuilder + func metaRow() -> some View { + HStack(spacing: 10) { + Text("#\(battle.category.title)") + .pickeBadge(.filled, size: .tag) + + Spacer(minLength: 0) + + HStack(spacing: 2) { + Image(systemName: "clock") + .pretendardFont(.labelXSmall) + Text("\(battle.durationMinutes)분") + .pretendardFont(.labelSmall) + } + .foregroundStyle(.gray300) + + previewButton + + Image(systemName: "checkmark") + .pickeCheckbox(isChecked: isSelected) + } + } + + var previewButton: some View { + Button(action: onPreview) { + HStack(spacing: 2) { + Image(systemName: "play.fill") + .pretendardFont(.labelXSmall) + Text("미리듣기") + .pretendardFont(.semiBold12) + } + .foregroundStyle(.gray300) + } + .buttonStyle(.plain) + .disabled(true) + .accessibilityHint("미리듣기는 준비 중입니다") + } + + @ViewBuilder + func titleSection() -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(battle.title) + .pretendardFont(.headingSmall) + .foregroundStyle(.gray500) + .lineLimit(1) + + Text(battle.summary) + .pretendardFont(.labelSmall) + .foregroundStyle(.gray200) + .lineLimit(1) + } + } + + @ViewBuilder + func versusRow() -> some View { + HStack(spacing: 8) { + optionSide( + title: battle.optionATitle, + philosopher: battle.philosopherA, + imageURL: battle.philosopherAImageURL + ) + + versusBadge + + optionSide( + title: battle.optionBTitle, + philosopher: battle.philosopherB, + imageURL: battle.philosopherBImageURL + ) + } + } + + @ViewBuilder + func optionSide( + title: String, + philosopher: String, + imageURL: URL? + ) -> some View { + HStack(spacing: 4) { + PickeAvatarView( + imageURL: imageURL?.absoluteString, + fallback: philosopher, + size: 40 + ) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .pretendardFont(.semiBold11) + .foregroundStyle(.gray500) + Text(philosopher) + .pretendardFont(.regular10) + .foregroundStyle(.gray300) + } + .lineLimit(1) + + Spacer(minLength: 0) + } + .padding(8) + .frame(maxWidth: .infinity) + .pickeCard(.beige300, border: .beige600) + } + + var versusBadge: some View { + Text("VS") + .pretendardFont(.bold8) + .foregroundStyle(.gray900) + .frame(width: 24, height: 24) + .background(.secondary200, in: Circle()) + } +} diff --git a/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassRecommendSkeletonView.swift b/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassRecommendSkeletonView.swift new file mode 100644 index 00000000..4f01008b --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassRecommend/View/Components/ClassRecommendSkeletonView.swift @@ -0,0 +1,77 @@ +// +// ClassRecommendSkeletonView.swift +// Class +// + +import PickeDesignKit +import SwiftUI + +/// 추천 결과 로딩 자리표시자. 결과 제목 + `ClassBattleListCard` 와 같은 자리에 블록을 둔다. +struct ClassRecommendSkeletonView: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + bar(width: 76, height: 20) + + ForEach(0 ..< 3, id: \.self) { _ in + card() + } + } + .allowsHitTesting(false) + } +} + +private extension ClassRecommendSkeletonView { + @ViewBuilder + func card() -> some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { + metaRow() + titleSection() + } + versusRow() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .pickeCard(.beige50, border: .beige600) + } + + @ViewBuilder + func metaRow() -> some View { + HStack(spacing: 10) { + bar(width: 41, height: 21) + Spacer(minLength: 0) + bar(width: 36, height: 17) + bar(width: 60, height: 17) + SkeletonView(.round(cornerRadius: 10)) + .frame(width: 20, height: 20) + } + } + + @ViewBuilder + func titleSection() -> some View { + VStack(alignment: .leading, spacing: 4) { + bar(width: 200, height: 18) + bar(width: 280, height: 17) + } + } + + @ViewBuilder + func versusRow() -> some View { + HStack(spacing: 8) { + SkeletonView(.round(cornerRadius: 2)) + .frame(height: 58) + SkeletonView(.round(cornerRadius: 12)) + .frame(width: 24, height: 24) + SkeletonView(.round(cornerRadius: 2)) + .frame(height: 58) + } + } + + func bar( + width: CGFloat, + height: CGFloat + ) -> some View { + SkeletonView(.round(cornerRadius: 2)) + .frame(width: width, height: height) + } +} diff --git a/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift b/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift new file mode 100644 index 00000000..801b5eb8 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift @@ -0,0 +1,55 @@ +import ClassDomainInterface +import ComposableArchitecture + +@Reducer +public struct ClassReportFeature { + public init() {} + + public enum Tab: String, CaseIterable, Hashable { + case summary = "요약" + case participation = "내 참여" + case classResult = "클래스 결과" + case feedback = "피드백" + } + + @ObservableState + public struct State: Equatable { + public var room: ClassRoom + public var selectedTab: Tab = .summary + + public init(room: ClassRoom) { + self.room = room + } + } + + public enum Action: ViewAction { + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View: Equatable { + case backTapped + case tabSelected(Tab) + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.backTapped): + return .send(.delegate(.dismiss)) + case let .view(.tabSelected(tab)): + guard state.selectedTab != tab else { return .none } + state.selectedTab = tab + return .none + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassReport/View/ClassReportPreviewContent.swift b/Projects/Feature/Class/Sources/ClassReport/View/ClassReportPreviewContent.swift new file mode 100644 index 00000000..5a293b0c --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassReport/View/ClassReportPreviewContent.swift @@ -0,0 +1,70 @@ +import CoreGraphics + +struct ClassReportPreviewContent { + enum Key: Hashable { + case aiSummaryTitle, aiSummaryDetail, teacherSummary + case commentStance, commentDate, commentBody, commentLikes, commentReplies + case replyAuthor, replyParent, replyBody, replyDate, replyLikes + case participantTitle, participantCount, classSummary + case bestAuthor, bestBody, bestLikes, bestReplies + case feedbackTitle1, feedbackDetail1, feedbackTitle2, feedbackDetail2 + case feedbackTitle3, feedbackDetail3, nextQuestion, nextHint + case teacherFeedback, teacherName, teacherDate + case initialChoice, finalChoice, commentCount, replyCount, receivedLikeCount + } + + let texts: [Key: String] + let initialVoteLeading: CGFloat + let finalVoteLeading: CGFloat + + subscript(_ key: Key) -> String { + texts[key] ?? "" + } +} + +#if DEBUG + extension ClassReportPreviewContent { + static let figma = Self( + texts: [ + .aiSummaryTitle: "다른 해결방법까지 생각을 확장했어요", + .aiSummaryDetail: "“교육과 보호도 함께 필요하다”는 대댓글에서 처벌 외의 대안을 제시했어요", + .teacherSummary: "“왜 생각이 달라졌는지 자신의 말로 설명한 점이 좋았어요”", + .commentStance: "낮춰야한다", + .commentDate: "26.09.23. 14:02", + .commentBody: "제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요. 네덜란드 사례를 보면 우려가 현실이 되고 있죠.", + .commentLikes: "1,340", + .commentReplies: "23", + .replyAuthor: "김민지 · 교정이 우선이다", + .replyParent: "제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요.", + .replyBody: "토론을 들으면서 처벌만으로는 해결되지 않는 점을 이해하게 됐어요. 교육과 보호도 함께 필요하다고 생각해요.", + .replyDate: "26.09.23. 14:02", + .replyLikes: "12", + .participantTitle: "참여한 학생", + .participantCount: "28 / 32", + .classSummary: "입장을 바꾼 학생은 6명이에요.\n연령하향 → 교정우선 5명, 교정 우선 → 연령 하향 1명이에요.\n교정 우선 선택은 10명에서 14명으로 늘었어요.", + .bestAuthor: "김민지 · 교정이 우선이다", + .bestBody: "“피해자 보호를 위한 기준도 함께 필요하지 않을까요?”", + .bestLikes: "1,340", + .bestReplies: "23", + .feedbackTitle1: "내 생각의 변화를 설명했어요", + .feedbackDetail1: "“처벌만으로는 해결되지 않는 점”을 언급하며, 처음과 입장이 달리진 이유를 드러냈어요.", + .feedbackTitle2: "다른 해결방법을 제안했어요", + .feedbackDetail2: "반대의견에 그치지 않고 “교육과 보호”라는 대안을 덧붙였어요.", + .feedbackTitle3: "근거를 한 단계 더 구체적으로", + .feedbackDetail3: "교육과 보호가 재범을 줄이는 데 어떤 도움이 되는지, 사례나 자료 하나를 연결하면 주장이 더 설득력 있어져요.", + .nextQuestion: "처벌과 교화를 함께 한다면,\n무엇을 기준으로 균형을 잡을까요?", + .nextHint: "내 의견과 반대되는 입장도 함께 떠올려 보세요.", + .teacherFeedback: "처음과 나중의 생각이 어떻게 달라졌는지 스스로 정리한 점이 좋았어요. 단순히 입장을 바꾸는 데서 끝나지 않고, 왜 바뀌었는지 설명하려는 태도가 인상적이었습니다.", + .teacherName: "김민지 선생님", + .teacherDate: "26.09.23. 14:02", + .initialChoice: "낮춰야 한다", + .finalChoice: "교정이 우선이다", + .commentCount: "1", + .replyCount: "12", + .receivedLikeCount: "8", + ], + initialVoteLeading: 0.64, + finalVoteLeading: 0.50 + ) + } +#endif diff --git a/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift b/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift new file mode 100644 index 00000000..9bcdde8d --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift @@ -0,0 +1,482 @@ +import ComposableArchitecture +import PickeDesignKit +import SwiftUI + +@ViewAction(for: ClassReportFeature.self) +public struct ClassReportView: View { + @Bindable public var store: StoreOf + private let previewContent: ClassReportPreviewContent? + + public init(store: StoreOf) { + self.store = store + previewContent = nil + } + + #if DEBUG + init(store: StoreOf, previewContent: ClassReportPreviewContent) { + self.store = store + self.previewContent = previewContent + } + #endif + + private var showsFigmaPreview: Bool { + previewContent != nil + } + + private func previewText(_ key: ClassReportPreviewContent.Key) -> String { + previewContent?[key] ?? "" + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "내 배틀 리포트") { + Image(systemName: "ellipsis") + .font(.system(size: 20)) + .frame(width: 24, height: 24) + } + .foregroundStyle(.gray800) + + reportTitle() + .padding(.top, 16) + reportTabs() + .padding(.top, 16) + reportPages() + } + .screenBackground() + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + } +} + +private extension ClassReportView { + @ViewBuilder + func reportTitle() -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text("참여완료").pickeBadge(.filled, size: .tag) + Text("#\(store.room.battle.category.title)").pickeBadge(.filled, size: .tag) + } + Text(store.room.battle.title) + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + } + + @ViewBuilder + func reportTabs() -> some View { + HStack(spacing: 0) { + ForEach(ClassReportFeature.Tab.allCases, id: \.self) { tab in + Button { send(.tabSelected(tab)) } label: { + Text(tab.rawValue) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(store.selectedTab == tab ? .primary500 : .gray300) + .frame(maxWidth: .infinity) + .frame(height: 44) + .overlay(alignment: .bottom) { + Rectangle() + .fill(store.selectedTab == tab ? .primary500 : .gray100) + .frame(height: store.selectedTab == tab ? 2 : 1) + } + } + .buttonStyle(.plain) + } + } + } + + @ViewBuilder + func reportPages() -> some View { + TabView(selection: Binding( + get: { store.selectedTab }, + set: { send(.tabSelected($0)) } + )) { + ForEach(ClassReportFeature.Tab.allCases, id: \.self) { tab in + ScrollView { + reportContent(for: tab) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 16) + } + .scrollIndicators(.hidden) + .tag(tab) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .animation(.easeInOut(duration: 0.25), value: store.selectedTab) + } + + @ViewBuilder + func reportContent(for tab: ClassReportFeature.Tab) -> some View { + VStack(spacing: 16) { + switch tab { + case .summary: + summaryContent() + case .participation: + participationContent() + case .classResult: + classResultContent() + case .feedback: + feedbackContent() + } + } + } + + @ViewBuilder + func summaryContent() -> some View { + reportCard(minHeight: showsFigmaPreview ? 204 : 0) { + cardHeading("내 참여 요약", destination: .participation) + voteAndActivitySummary() + } + reportCard(minHeight: showsFigmaPreview ? 148 : 0) { + cardHeading("AI 리포트", destination: .feedback) + if showsFigmaPreview { + Text(previewText(.aiSummaryTitle)) + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + Text(previewText(.aiSummaryDetail)) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + } else { + unavailableReport( + title: "AI 리포트를 준비하고 있어요", + message: "작성한 의견이 분석되면 이곳에서 확인할 수 있어요." + ) + } + } + reportCard(minHeight: showsFigmaPreview ? 95 : 0) { + cardHeading("선생님 피드백", destination: .feedback) + if showsFigmaPreview { + Text(previewText(.teacherSummary)) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + } else { + unavailableReport( + title: "선생님 피드백 데이터를 불러올 수 없어요", + message: "선생님 피드백이 등록되면 이곳에서 확인할 수 있어요." + ) + } + } + } + + @ViewBuilder + func participationContent() -> some View { + reportCard(minHeight: showsFigmaPreview ? 204 : 0) { + cardHeading("내 투표 결과") + voteAndActivitySummary() + } + reportCard(padding: 16, spacing: 8, minHeight: showsFigmaPreview ? 177 : 0) { + cardHeading("내 댓글") + if showsFigmaPreview { + HStack { + Text(previewText(.commentStance)).pickeBadge(.filled, size: .tag) + Spacer() + Text(previewText(.commentDate)).pretendardFont(family: .Regular, size: 12).foregroundStyle(.gray300) + } + Text(previewText(.commentBody)) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + reactions(likes: previewText(.commentLikes), replies: previewText(.commentReplies)) + } else { + unavailableReport( + title: "댓글 데이터를 불러올 수 없어요", + message: "댓글 데이터가 연결되면 이곳에서 확인할 수 있어요." + ) + } + } + reportCard(padding: 16, spacing: 8, minHeight: showsFigmaPreview ? 225 : 0) { + cardHeading("내 대댓글") + if showsFigmaPreview { + Text(previewText(.replyAuthor)) + .pretendardFont(.semiBold13) + .foregroundStyle(.gray800) + Text(previewText(.replyParent)) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + Rectangle().fill(.beige600).frame(height: 1) + Text("내 대댓글").pretendardFont(.semiBold13).foregroundStyle(.gray800) + Text(previewText(.replyBody)) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + HStack { + Text(previewText(.replyDate)) + Spacer() + Image(systemName: "heart") + Text(previewText(.replyLikes)) + } + .pretendardFont(family: .Regular, size: 12) + .foregroundStyle(.gray300) + } else { + unavailableReport( + title: "대댓글 데이터를 불러올 수 없어요", + message: "대댓글 데이터가 연결되면 이곳에서 확인할 수 있어요." + ) + } + } + } + + @ViewBuilder + func classResultContent() -> some View { + reportCard(minHeight: showsFigmaPreview ? 243 : 0) { + cardHeading("우리 클래스 참여 현황") + HStack { + Text(showsFigmaPreview ? previewText(.participantTitle) : "클래스 멤버") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + Spacer() + Text(showsFigmaPreview ? previewText(.participantCount) : "\(store.room.memberCount)명") + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + } + if showsFigmaPreview { + voteDistribution(title: "사전 투표 결과", leading: previewContent?.initialVoteLeading ?? 0) + voteDistribution(title: "사후 투표 결과", leading: previewContent?.finalVoteLeading ?? 0) + } else { + unavailableReport( + title: "투표 분포를 집계할 수 없어요", + message: "현재 클래스 데이터에는 투표 집계가 포함되어 있지 않아요." + ) + } + } + reportCard(padding: 16, spacing: 8, minHeight: showsFigmaPreview ? 120 : 0) { + cardHeading("클래스 결과 요약") + if showsFigmaPreview { + Text(previewText(.classSummary)) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + } else { + Text("\(store.room.battle.optionATitle)\n\(store.room.battle.optionBTitle)") + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + Text("두 선택지에 대한 클래스 투표 결과가 연결되면 비교해 드릴게요.") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + } + reportCard(padding: 16, spacing: 8, minHeight: showsFigmaPreview ? 142 : 0) { + cardHeading("BEST 댓글") + if showsFigmaPreview { + Text(previewText(.bestAuthor)) + .pretendardFont(.semiBold13) + .foregroundStyle(.gray800) + Text(previewText(.bestBody)) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + reactions(likes: previewText(.bestLikes), replies: previewText(.bestReplies)) + } else { + unavailableReport( + title: "BEST 댓글 데이터를 불러올 수 없어요", + message: "댓글 반응 데이터가 연결되면 가장 많은 공감을 받은 댓글을 보여드릴게요." + ) + } + } + } + + @ViewBuilder + func feedbackContent() -> some View { + reportCard(minHeight: showsFigmaPreview ? 361 : 0) { + cardHeading("AI 피드백") + if showsFigmaPreview { + feedbackPoint(previewText(.feedbackTitle1), detail: previewText(.feedbackDetail1)) + feedbackPoint(previewText(.feedbackTitle2), detail: previewText(.feedbackDetail2)) + feedbackPoint(previewText(.feedbackTitle3), detail: previewText(.feedbackDetail3)) + Text("분석에 사용한 내 대댓글 보기") + .pretendardFont(.semiBold13) + .foregroundStyle(.primary500) + } else { + unavailableReport( + title: "AI 분석 결과가 아직 없어요", + message: "내 의견과 댓글 데이터가 연결되면 생각의 변화와 근거를 분석해 드릴게요." + ) + } + } + reportCard(padding: 16, spacing: 8, minHeight: showsFigmaPreview ? 138 : 0) { + cardHeading("다음 생각 해보기") + Text(showsFigmaPreview ? previewText(.nextQuestion) : store.room.battle.title) + .pretendardFont(family: .SemiBold, size: 18) + .foregroundStyle(.gray700) + Text(showsFigmaPreview ? previewText(.nextHint) : "두 선택지를 비교하며 내 생각을 정리해 보세요.") + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + reportCard(padding: 16, spacing: 8, minHeight: showsFigmaPreview ? 142 : 0) { + cardHeading("선생님 피드백") + if showsFigmaPreview { + Text(previewText(.teacherFeedback)) + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + HStack { + Text(previewText(.teacherName)) + Spacer() + Text(previewText(.teacherDate)) + } + .pretendardFont(family: .Regular, size: 12) + .foregroundStyle(.gray300) + } else { + unavailableReport( + title: "선생님 피드백 데이터를 불러올 수 없어요", + message: "선생님이 피드백을 남기면 이곳에서 확인할 수 있어요." + ) + } + } + } + + @ViewBuilder + func voteAndActivitySummary() -> some View { + if showsFigmaPreview { + HStack(spacing: 8) { + voteChoice("사전 선택", value: previewText(.initialChoice)) + VStack(spacing: 4) { + Image(systemName: "arrow.right") + Text("입장 변경").pretendardFont(family: .Regular, size: 12) + } + .foregroundStyle(.primary500) + voteChoice("사후 선택", value: previewText(.finalChoice)) + } + .frame(maxWidth: .infinity) + HStack(spacing: 0) { + activityCount(previewText(.commentCount), label: "댓글") + activityCount(previewText(.replyCount), label: "대댓글") + activityCount(previewText(.receivedLikeCount), label: "받은 좋아요") + } + } else { + unavailableReport( + title: "내 투표와 참여 기록을 불러올 수 없어요", + message: "리포트 데이터가 연결되면 이곳에서 확인할 수 있어요." + ) + } + } + + func voteChoice(_ title: String, value: String) -> some View { + VStack(spacing: 4) { + Text(title).pretendardFont(family: .Regular, size: 12).foregroundStyle(.gray300) + Text(value).pretendardFont(.semiBold13).foregroundStyle(.gray800) + } + .frame(maxWidth: .infinity) + } + + func activityCount(_ count: String, label: String) -> some View { + VStack(spacing: 2) { + Text(count).pretendardFont(family: .SemiBold, size: 18).foregroundStyle(.gray800) + Text(label).pretendardFont(.regular13).foregroundStyle(.gray300) + } + .frame(maxWidth: .infinity) + } + + func reactions(likes: String, replies: String) -> some View { + HStack(spacing: 16) { + Label(likes, systemImage: "heart") + Label(replies, systemImage: "bubble.right") + } + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + + func voteDistribution(title: String, leading: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title).pretendardFont(.regular13).foregroundStyle(.gray700) + GeometryReader { geometry in + HStack(spacing: 0) { + Rectangle().fill(Color.primary500).frame(width: geometry.size.width * leading) + Rectangle().fill(Color.gray300) + } + } + .frame(height: 8) + HStack { + Text(store.room.battle.optionATitle) + Spacer() + Text(store.room.battle.optionBTitle) + } + .pretendardFont(.regular13) + .foregroundStyle(.gray700) + } + } + + func feedbackPoint(_ title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.primary500) + VStack(alignment: .leading, spacing: 4) { + Text(title).pretendardFont(.semiBold15).foregroundStyle(.gray800) + Text(detail).pretendardFont(.regular13).foregroundStyle(.gray700) + } + } + .padding(.vertical, 4) + } + + @ViewBuilder + func reportCard( + padding: CGFloat = 12, + spacing: CGFloat = 12, + minHeight: CGFloat = 0, + @ViewBuilder content: () -> some View + ) -> some View { + VStack(alignment: .leading, spacing: spacing, content: content) + .padding(.vertical, padding) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: minHeight, alignment: .top) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1)) + } + + @ViewBuilder + func cardHeading(_ title: String, destination: ClassReportFeature.Tab? = nil) -> some View { + HStack { + Text(title) + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + Spacer(minLength: 0) + if let destination { + Button { send(.tabSelected(destination)) } label: { + HStack(spacing: 4) { + Text("보기") + .pretendardFont(family: .Bold, size: 12) + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + } + .foregroundStyle(.primary500) + } + .buttonStyle(.plain) + } + } + .frame(height: 39) + } + + @ViewBuilder + func unavailableReport(title: String, message: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + Text(message) + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + } + } +} + +#if DEBUG + private func figmaReportStore(tab: ClassReportFeature.Tab) -> StoreOf { + var state = ClassReportFeature.State(room: .mocks[0]) + state.selectedTab = tab + return Store(initialState: state) { ClassReportFeature() } + } + + #Preview("리포트 · 요약") { + ClassReportView(store: figmaReportStore(tab: .summary), previewContent: .figma) + } + + #Preview("리포트 · 내 참여") { + ClassReportView(store: figmaReportStore(tab: .participation), previewContent: .figma) + } + + #Preview("리포트 · 클래스 결과") { + ClassReportView(store: figmaReportStore(tab: .classResult), previewContent: .figma) + } + + #Preview("리포트 · 피드백") { + ClassReportView(store: figmaReportStore(tab: .feedback), previewContent: .figma) + } +#endif diff --git a/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift b/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift new file mode 100644 index 00000000..0c600271 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift @@ -0,0 +1,169 @@ +// +// ClassSettingFeature.swift +// Class +// + +import ClassDomainInterface +import ComposableArchitecture +import Foundation + +@Reducer +public struct ClassSettingFeature { + @Dependency(\.classUseCase) private var classUseCase + + public init() {} + + @ObservableState + public struct State: Equatable { + public var battle: ClassBattleSummary + public var name = "" + public var deadline: Date + public var isDeadlineEnabled = true + public var isDatePickerPresented = false + public var requiresComment = false + public var isLoading = false + + public init( + battle: ClassBattleSummary, + deadline: Date = Date().addingTimeInterval(7 * 24 * 60 * 60) + ) { + self.battle = battle + self.deadline = deadline + } + + public var deadlineText: String { + Self.deadlineFormatter.string(from: deadline) + } + + public var canCreate: Bool { + !trimmedName.isEmpty && !isLoading + } + + public var creation: ClassCreation { + ClassCreation( + name: trimmedName, + deadline: isDeadlineEnabled ? deadline : .distantFuture, + battleId: battle.id, + allowsAnonymousOpinion: false, + requiresComment: requiresComment + ) + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static let deadlineFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy년 MM월 dd일 HH:mm" + return formatter + }() + } + + 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 backTapped + case deadlineFieldTapped + case createTapped + } + + public enum AsyncAction: Equatable { + case create(ClassCreation) + } + + @CasePathable + public enum InnerAction: Equatable { + case created(Result) + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + case created(ClassRoom) + } + + nonisolated enum CancelID: Hashable { + case create + } + + 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 ClassSettingFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + + case .deadlineFieldTapped: + state.isDatePickerPresented.toggle() + return .none + + case .createTapped: + guard state.canCreate else { return .none } + return .send(.async(.create(state.creation))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .create(creation): + state.isLoading = true + return .run { [classUseCase] send in + let result = await Result { + try await classUseCase.createClass(creation) + } + .mapError(ClassError.from) + await send(.inner(.created(result))) + } + .cancellable(id: CancelID.create, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .created(result): + state.isLoading = false + guard case let .success(room) = result else { return .none } + return .send(.delegate(.created(room))) + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassSetting/View/ClassSettingView.swift b/Projects/Feature/Class/Sources/ClassSetting/View/ClassSettingView.swift new file mode 100644 index 00000000..32df92a6 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassSetting/View/ClassSettingView.swift @@ -0,0 +1,157 @@ +// +// ClassSettingView.swift +// Class +// + +import ClassDomainInterface +import ComposableArchitecture +import PickeCoreUtility +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: ClassSettingFeature.self) +public struct ClassSettingView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar( + onBack: { send(.backTapped) }, + centerTitle: "클래스 설정" + ) + .foregroundStyle(.gray500) + + ScrollView { + VStack(alignment: .leading, spacing: 32) { + Text("수업 정보에 맞게\n클래스를 설정해 주세요") + .pretendardFont(.semiBold24) + .lineSpacing(2.4) + .foregroundStyle(.gray500) + + nameSection() + deadlineSection() + battleSection() + commentToggle + } + .padding(16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + + createButton + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .screenBackground() + .hidesSystemBars() + } +} + +private extension ClassSettingView { + @ViewBuilder + func nameSection() -> some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("클래스 명") + + TextField( + "", + text: $store.name, + prompt: Text("ex) 2학년 3반 1학기 토론") + .font( + .pretendardFontFamily( + family: .Medium, + size: 13 + ) + ) + .foregroundStyle(.gray300) + ) + .pickeTextField() + } + } + + @ViewBuilder + func deadlineSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + Toggle(isOn: $store.isDeadlineEnabled) { + VStack(alignment: .leading, spacing: 2) { + sectionLabel("참여 마감일") + Text("마감 전까지 학생이 클래스에 참여할 수 있어요.") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) + } + } + .toggleStyle(.picke) + + if store.isDeadlineEnabled { + deadlineField + } + + if store.isDeadlineEnabled, store.isDatePickerPresented { + DatePicker( + "", + selection: $store.deadline, + in: Date()..., + displayedComponents: [.date, .hourAndMinute] + ) + .datePickerStyle(.graphical) + .tint(.primary500) + .pickeCard(.beige50, border: .beige600) + } + } + } + + var deadlineField: some View { + Button { + send(.deadlineFieldTapped) + } label: { + Text(store.deadlineText) + .pretendardFont(.labelMedium) + .foregroundStyle(.gray800) + .padding(.leading, 8) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .pickeCard(.beige50, border: .beige600) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func battleSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + sectionLabel("선택한 배틀") + ClassSelectedBattleCard(battle: store.battle) + } + } + + var commentToggle: some View { + Toggle(isOn: $store.requiresComment) { + VStack(alignment: .leading, spacing: 2) { + sectionLabel("댓글 달기 필수") + Text("학생들의 생각을 댓글로 확인해요.") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) + } + } + .toggleStyle(.picke) + } + + func sectionLabel(_ title: String) -> some View { + Text(title) + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + } + + var createButton: some View { + Button { + send(.createTapped) + } label: { + Text("클래스 만들기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(!store.canCreate) + } +} diff --git a/Projects/Feature/Class/Sources/ClassShare/Reducer/ClassShareFeature.swift b/Projects/Feature/Class/Sources/ClassShare/Reducer/ClassShareFeature.swift new file mode 100644 index 00000000..11e35222 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassShare/Reducer/ClassShareFeature.swift @@ -0,0 +1,71 @@ +// +// ClassShareFeature.swift +// Class +// + +import ClassDomainInterface +import ComposableArchitecture +import Foundation + +@Reducer +public struct ClassShareFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var room: ClassRoom + + public init(room: ClassRoom) { + self.room = room + } + + /// 마감일을 끈 클래스는 `.distantFuture` 로 만들어지므로 표시하지 않는다. + public var deadlineText: String? { + guard room.deadline != .distantFuture else { return nil } + return "참여 마감 \(Self.deadlineFormatter.string(from: room.deadline))" + } + + public var shareMessage: String { + "[Picke] '\(room.name)' 클래스에 참여해 주세요!\n참여 코드: \(room.joinCode)" + } + + private static let deadlineFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy년 MM월 dd일 HH:mm" + return formatter + }() + } + + public enum Action: ViewAction { + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case closeTapped + case enterClassTapped + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + case enterClass(ClassRoom) + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.closeTapped): + return .send(.delegate(.dismiss)) + + case .view(.enterClassTapped): + return .send(.delegate(.enterClass(state.room))) + + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassShare/View/ClassShareView.swift b/Projects/Feature/Class/Sources/ClassShare/View/ClassShareView.swift new file mode 100644 index 00000000..e429132b --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassShare/View/ClassShareView.swift @@ -0,0 +1,124 @@ +// +// ClassShareView.swift +// Class +// + +import ComposableArchitecture +import PickeDesignKit +import SwiftUI + +@ViewAction(for: ClassShareFeature.self) +public struct ClassShareView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar( + onBack: { send(.closeTapped) }, + centerTitle: "클래스 공유하기" + ) + .foregroundStyle(.gray500) + + ScrollView { + VStack(alignment: .leading, spacing: 32) { + intro() + codeCard() + battleSection() + } + .padding(16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + + enterButton + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .screenBackground() + .hidesSystemBars() + } +} + +private extension ClassShareView { + @ViewBuilder + func intro() -> some View { + VStack(spacing: 12) { + Text("클래스가 준비됐어요") + .pretendardFont(.semiBold24) + .foregroundStyle(.gray500) + + Text("참여 코드를 학생들에게 공유해주세요") + .pretendardFont(.headingMedium) + .foregroundStyle(.gray300) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + func codeCard() -> some View { + VStack(spacing: 12) { + VStack(spacing: 4) { + Text(store.room.name) + .pretendardFont(.bold18) + .foregroundStyle(.gray800) + + if let deadlineText = store.deadlineText { + Text(deadlineText) + .pretendardFont(.labelMedium) + .foregroundStyle(.gray300) + } + } + + VStack(spacing: 12) { + Text("참여 코드") + .pretendardFont(.labelMedium) + .foregroundStyle(.gray300) + + Text(store.room.joinCode) + .pretendardFont(.headingXXLarge) + .foregroundStyle(.primary500) + .textSelection(.enabled) + } + + shareButton + } + .padding(24) + .frame(maxWidth: .infinity) + .pickeCard(.beige50, border: .beige600) + } + + var shareButton: some View { + ShareLink(item: store.shareMessage) { + Text("코드 공유하기") + .pretendardFont(.labelLarge) + .foregroundStyle(.primary500) + .frame(maxWidth: .infinity, minHeight: 52) + .background(.primary50, in: RoundedRectangle(cornerRadius: .radiusDefault)) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func battleSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("선택한 배틀") + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + + ClassSelectedBattleCard(battle: store.room.battle) + } + } + + var enterButton: some View { + Button { + send(.enterClassTapped) + } label: { + Text("클래스로 이동하기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + } +} diff --git a/Projects/Feature/Class/Sources/ClassTopic/Reducer/ClassTopicFeature.swift b/Projects/Feature/Class/Sources/ClassTopic/Reducer/ClassTopicFeature.swift new file mode 100644 index 00000000..26ed83d1 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassTopic/Reducer/ClassTopicFeature.swift @@ -0,0 +1,85 @@ +// +// ClassTopicFeature.swift +// Class +// + +import Foundation + +import ClassDomainInterface +import ComposableArchitecture + +@Reducer +public struct ClassTopicFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var keyword: String = "" + public var level: ClassAudienceLevel = .middle + public var category: ClassCategory? + + public init() {} + + public var filter: ClassTopicFilter { + ClassTopicFilter( + keyword: keyword.trimmingCharacters(in: .whitespacesAndNewlines), + level: level, + category: category + ) + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backTapped + case categoryTapped(ClassCategory) + case searchTapped + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + case search(ClassTopicFilter) + } + + 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 .delegate: + return .none + } + } + } +} + +extension ClassTopicFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .categoryTapped(category): + state.category = state.category == category ? nil : category + return .none + + case .searchTapped: + return .send(.delegate(.search(state.filter))) + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassTopic/View/ClassTopicView.swift b/Projects/Feature/Class/Sources/ClassTopic/View/ClassTopicView.swift new file mode 100644 index 00000000..e9eeb411 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassTopic/View/ClassTopicView.swift @@ -0,0 +1,145 @@ +// +// ClassTopicView.swift +// Class +// + +import ClassDomainInterface +import ComposableArchitecture +import PickeDesignKit +import SwiftUI + +@ViewAction(for: ClassTopicFeature.self) +public struct ClassTopicView: View { + @Bindable public var store: StoreOf + @Namespace private var levelNamespace + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar( + onBack: { send(.backTapped) }, + centerTitle: "새 클래스 만들기" + ) + .foregroundStyle(.gray500) + + ScrollView { + VStack(alignment: .leading, spacing: 32) { + titleSection() + formSection() + } + .padding(16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + + searchButton + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .screenBackground() + .hidesSystemBars() + } +} + +private extension ClassTopicView { + @ViewBuilder + func titleSection() -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("어떤 주제로\n이야기 나눌까요?") + .pretendardFont(.semiBold24) + .lineSpacing(2.4) + .foregroundStyle(.gray800) + + Text("관심 있는 주제나 수업 조건을 선택해 주세요.") + .pretendardFont(.medium15) + .foregroundStyle(.gray300) + } + } + + @ViewBuilder + func formSection() -> some View { + VStack(alignment: .leading, spacing: 20) { + field("찾고 싶은 주제") { + TextField( + "", + text: $store.keyword, + prompt: Text("예) 촉법소년 · AI가 인간의 일을 대신해도 될까?") + .foregroundStyle(.gray300) + ) + .pickeTextField() + } + + field("대상 수준") { + levelSegment() + } + + field("카테고리") { + categoryGrid() + } + } + } + + @ViewBuilder + func field( + _ label: String, + @ViewBuilder content: () -> some View + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(label) + .pretendardFont(.semiBold15) + .foregroundStyle(.gray800) + content() + } + } + + @ViewBuilder + func levelSegment() -> some View { + HStack(spacing: 0) { + ForEach(ClassAudienceLevel.allCases, id: \.self) { level in + Button { + withAnimation(.snappy(duration: 0.25)) { + store.level = level + } + } label: { + Text(level.title) + .pickeBoxSegment( + isSelected: store.level == level, + namespace: levelNamespace + ) + } + .buttonStyle(.plain) + } + } + .pickeBoxSegmentTrack() + } + + @ViewBuilder + func categoryGrid() -> some View { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), + spacing: 8 + ) { + ForEach(ClassCategory.allCases, id: \.self) { category in + Button { + send(.categoryTapped(category)) + } label: { + Text(category.title) + .pickeChoiceChip(isSelected: store.category == category) + } + .buttonStyle(.plain) + } + } + } + + var searchButton: some View { + Button { + send(.searchTapped) + } label: { + Text("조건에 맞는 콘텐츠 보기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + } +} diff --git a/Projects/Feature/Class/Sources/Common/Components/ClassSelectedBattleCard.swift b/Projects/Feature/Class/Sources/Common/Components/ClassSelectedBattleCard.swift new file mode 100644 index 00000000..27c14cb4 --- /dev/null +++ b/Projects/Feature/Class/Sources/Common/Components/ClassSelectedBattleCard.swift @@ -0,0 +1,74 @@ +// +// ClassSelectedBattleCard.swift +// Class +// + +import ClassDomainInterface +import PickeCoreUtility +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +/// 클래스 설정 / 공유 화면의 "선택한 배틀" 카드. +struct ClassSelectedBattleCard: View { + let battle: ClassBattleSummary + + var body: some View { + HStack(alignment: .top, spacing: 8) { + PickeRemoteImage(url: battle.thumbnailURL) { Color.beige600 } + .frame(width: 76, height: 106) + .clipShape(RoundedRectangle(cornerRadius: .radiusDefault)) + + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text("#\(battle.category.title)") + .pickeBadge(.filled, size: .tag) + Text(battle.title) + .pretendardFont(.headingSmall) + .foregroundStyle(.gray500) + .lineLimit(1) + } + + Text(battle.summary) + .pretendardFont(.regular13) + .foregroundStyle(.gray400) + .lineLimit(2) + .padding(.horizontal, 2) + } + + HStack(spacing: 6) { + Spacer(minLength: 0) + metaLabel( + systemImage: "clock", + text: "\(battle.durationMinutes)분" + ) + metaLabel( + systemImage: "eye", + text: battle.viewCount.decimalFormatted + ) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .pickeCard(.beige50, border: .beige600, radius: 0) + } +} + +private extension ClassSelectedBattleCard { + @ViewBuilder + func metaLabel( + systemImage: String, + text: String + ) -> some View { + HStack(spacing: 2) { + Image(systemName: systemImage) + .pretendardFont(.labelXSmall) + Text(text) + .pretendardFont(.labelSmall) + } + .foregroundStyle(.gray300) + } +} diff --git a/Projects/Feature/Class/Sources/Modal/ClassModalFeature.swift b/Projects/Feature/Class/Sources/Modal/ClassModalFeature.swift new file mode 100644 index 00000000..2ae5893e --- /dev/null +++ b/Projects/Feature/Class/Sources/Modal/ClassModalFeature.swift @@ -0,0 +1,30 @@ +import ComposableArchitecture + +@Reducer +public struct ClassModalFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public enum Kind: Equatable { + case nickname + case management + case deadline + case code + } + + public let kind: Kind + + public init(kind: Kind) { + self.kind = kind + } + } + + public enum Action { + case dismissTapped + } + + public var body: some Reducer { + EmptyReducer() + } +} diff --git a/Projects/Feature/Class/Sources/MyClass/Reducer/MyClassFeature.swift b/Projects/Feature/Class/Sources/MyClass/Reducer/MyClassFeature.swift new file mode 100644 index 00000000..93c143fe --- /dev/null +++ b/Projects/Feature/Class/Sources/MyClass/Reducer/MyClassFeature.swift @@ -0,0 +1,139 @@ +import ClassDomainInterface +import ComposableArchitecture + +@Reducer +public struct MyClassFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public enum ViewState: Equatable { + case loading + case error + case empty + case loaded([ClassRoom]) + } + + public enum Progress: Hashable, CaseIterable { + case all + case open + case closed + } + + public var rooms: [ClassRoom] = [] + public var progress: Progress = .all + public var isLoading = false + public var errorMessage: String? + + public init() {} + + public var viewState: ViewState { + if errorMessage != nil && rooms.isEmpty { + return .error + } + if isLoading && rooms.isEmpty { + return .loading + } + let visible = visibleRooms + return visible.isEmpty ? .empty : .loaded(visible) + } + + public var visibleRooms: [ClassRoom] { + rooms.filter { room in + switch progress { + case .all: true + case .open: room.status == .open + case .closed: room.status == .closed + } + } + } + } + + public enum Action: ViewAction { + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + public enum View { + case onAppear + case retryTapped + case backTapped + case progressTapped(State.Progress) + case roomTapped(Int) + } + + public enum AsyncAction: Equatable { + case fetchRooms + } + + public enum InnerAction: Equatable { + case roomsResponse(Result<[ClassRoom], ClassError>) + } + + public enum DelegateAction: Equatable { + case dismiss + case openRoom(ClassRoom) + } + + @Dependency(\.classUseCase) private var classUseCase + + 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 MyClassFeature { + private func handleViewAction(state: inout State, action: View) -> Effect { + switch action { + case .onAppear, .retryTapped: + return .send(.async(.fetchRooms)) + case .backTapped: + return .send(.delegate(.dismiss)) + case let .progressTapped(progress): + state.progress = progress + return .none + case let .roomTapped(id): + guard let room = state.rooms.first(where: { $0.id == id }) else { return .none } + return .send(.delegate(.openRoom(room))) + } + } + + private func handleAsyncAction(state: inout State, action: AsyncAction) -> Effect { + switch action { + case .fetchRooms: + state.isLoading = true + state.errorMessage = nil + return .run { [classUseCase] send in + let result = await Result { + try await classUseCase.fetchMyClasses() + } + .mapError(ClassError.from) + await send(.inner(.roomsResponse(result))) + } + } + } + + private func handleInnerAction(state: inout State, action: InnerAction) -> Effect { + state.isLoading = false + switch action { + case let .roomsResponse(.success(rooms)): + state.rooms = rooms + case let .roomsResponse(.failure(error)): + state.errorMessage = String(describing: error) + } + return .none + } +} diff --git a/Projects/Feature/Class/Sources/MyClass/View/Components/MyClassRoomCard.swift b/Projects/Feature/Class/Sources/MyClass/View/Components/MyClassRoomCard.swift new file mode 100644 index 00000000..e857a25e --- /dev/null +++ b/Projects/Feature/Class/Sources/MyClass/View/Components/MyClassRoomCard.swift @@ -0,0 +1,146 @@ +// +// MyClassRoomCard.swift +// Class +// + +import ClassDomainInterface +import PickeDesignKit +import SwiftUI + +/// 내 클래스 목록 카드 (Figma 12178:8263 `Card/BattleListCard`). +struct MyClassRoomCard: View { + let room: ClassRoom + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + badgeRow() + titleSection() + } + + footer() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .pickeCard(.beige50, border: .beige600) + .contentShape(Rectangle()) + } +} + +private extension MyClassRoomCard { + @ViewBuilder + func badgeRow() -> some View { + HStack(alignment: .top, spacing: 10) { + statusBadge + + Spacer(minLength: 0) + + Image(systemName: "ellipsis") + .font(.pretendardFontFamily(family: .Bold, size: 14)) + .foregroundStyle(.gray900) + .frame(width: 24, height: 24) + } + } + + @ViewBuilder + var statusBadge: some View { + switch room.status { + case .open: + Text("진행 중") + .pickeBadge(.filled, size: .tag) + case .closed: + Text("종료") + .pretendardFont(.semiBold12) + .foregroundStyle(.gray300) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .roundedBackground(.gray50) + } + } + + @ViewBuilder + func titleSection() -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(room.name) + .pretendardFont(.headingMedium) + .foregroundStyle(.gray500) + .lineLimit(1) + + Text(room.battle.title) + .pretendardFont(.regular13) + .foregroundStyle(.gray300) + .lineLimit(1) + } + } + + @ViewBuilder + func footer() -> some View { + HStack(spacing: 6) { + metaItem( + systemName: "person", + text: "멤버 \(room.memberCount)명" + ) + + if let deadlineText { + Circle() + .fill(.gray300) + .frame(width: 2, height: 2) + + metaItem( + systemName: "calendar", + text: deadlineText + ) + } + + Spacer(minLength: 0) + + viewLabel + } + .padding(.top, 12) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } + + @ViewBuilder + func metaItem( + systemName: String, + text: String + ) -> some View { + HStack(spacing: 2) { + Image(systemName: systemName) + .font(.pretendardFontFamily(family: .Regular, size: 10)) + .frame(width: 12, height: 12) + Text(text) + .pretendardFont(.bodySmall) + .lineLimit(1) + } + .foregroundStyle(.gray300) + } + + var viewLabel: some View { + HStack(spacing: 4) { + Text("보기") + .pretendardFont(family: .Bold, size: 12) + Image(systemName: "chevron.right") + .font(.pretendardFontFamily(family: .Bold, size: 9)) + .frame(width: 12, height: 12) + } + .foregroundStyle(.primary500) + } + + /// 마감일을 끈 클래스(`.distantFuture`)는 표시하지 않는다. + var deadlineText: String? { + guard room.deadline != .distantFuture else { return nil } + return "\(Self.deadlineFormatter.string(from: room.deadline))까지" + } + + static let deadlineFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy. MM. dd. EEEE HH:mm" + return formatter + }() +} diff --git a/Projects/Feature/Class/Sources/MyClass/View/Components/MyClassSkeletonView.swift b/Projects/Feature/Class/Sources/MyClass/View/Components/MyClassSkeletonView.swift new file mode 100644 index 00000000..85d339b5 --- /dev/null +++ b/Projects/Feature/Class/Sources/MyClass/View/Components/MyClassSkeletonView.swift @@ -0,0 +1,76 @@ +// +// MyClassSkeletonView.swift +// Class +// + +import PickeDesignKit +import SwiftUI + +/// 내 클래스 목록 로딩 자리표시자. `MyClassRoomCard` 와 같은 자리에 블록을 둔다. +struct MyClassSkeletonView: View { + var body: some View { + VStack(spacing: 12) { + ForEach(0 ..< 3, id: \.self) { _ in + card() + } + } + .allowsHitTesting(false) + } +} + +private extension MyClassSkeletonView { + @ViewBuilder + func card() -> some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + badgeRow() + titleSection() + } + + footer() + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .pickeCard(.beige50, border: .beige600) + } + + @ViewBuilder + func badgeRow() -> some View { + HStack(spacing: 10) { + bar(width: 44, height: 21) + Spacer(minLength: 0) + bar(width: 24, height: 24) + } + } + + @ViewBuilder + func titleSection() -> some View { + VStack(alignment: .leading, spacing: 4) { + bar(width: 160, height: 21) + bar(width: 220, height: 17) + } + } + + @ViewBuilder + func footer() -> some View { + HStack(spacing: 6) { + bar(width: 180, height: 17) + Spacer(minLength: 0) + bar(width: 32, height: 17) + } + .padding(.top, 12) + .overlay(alignment: .top) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } + + func bar( + width: CGFloat, + height: CGFloat + ) -> some View { + SkeletonView(.round(cornerRadius: 2)) + .frame(width: width, height: height) + } +} diff --git a/Projects/Feature/Class/Sources/MyClass/View/MyClassView.swift b/Projects/Feature/Class/Sources/MyClass/View/MyClassView.swift new file mode 100644 index 00000000..dcc644ee --- /dev/null +++ b/Projects/Feature/Class/Sources/MyClass/View/MyClassView.swift @@ -0,0 +1,85 @@ +import ClassDomainInterface +import ComposableArchitecture +import PickeDesignKit +import PickeSharedUI +import SwiftUI + +@ViewAction(for: MyClassFeature.self) +public struct MyClassView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "내 클래스") + .foregroundStyle(.gray500) + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + progressChips() + roomList() + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .scrollIndicators(.hidden) + } + .screenBackground() + .toolbar(.hidden, for: .navigationBar) + .toolbar(.visible, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +private extension MyClassView { + @ViewBuilder + func progressChips() -> some View { + HStack(spacing: 8) { + ForEach(MyClassFeature.State.Progress.allCases, id: \.self) { progress in + Button { send(.progressTapped(progress)) } label: { + Text(progress.title) + .pickeSortChip(isSelected: store.progress == progress) + } + .buttonStyle(.plain) + } + } + } + + @ViewBuilder + func roomList() -> some View { + switch store.viewState { + case .error: + PickeRetryErrorView(message: "클래스를 불러오지 못했어요") { send(.retryTapped) } + .frame(maxWidth: .infinity, minHeight: 160) + case .loading: + MyClassSkeletonView() + case .empty: + Text("아직 클래스가 없어요") + .pretendardFont(.medium15) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity, minHeight: 160) + case let .loaded(rooms): + LazyVStack(spacing: 12) { + ForEach(rooms) { room in + Button { send(.roomTapped(room.id)) } label: { + MyClassRoomCard(room: room) + } + .buttonStyle(.plain) + } + } + } + } +} + +private extension MyClassFeature.State.Progress { + var title: String { + switch self { + case .all: "전체" + case .open: "진행 중" + case .closed: "종료" + } + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassDetailTests.swift b/Projects/Feature/Class/Tests/Sources/ClassDetailTests.swift new file mode 100644 index 00000000..8d38d6bd --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassDetailTests.swift @@ -0,0 +1,126 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import PickeSharedUI +import Testing + +@MainActor +struct ClassDetailTests { + @Test + func 선생님은_마감일을_수정할_수_있다() async { + let room = ClassRoom.mocks[0] + let store = TestStore(initialState: ClassDetailFeature.State(room: room)) { + ClassDetailFeature() + } + let updated = room.deadline.addingTimeInterval(86400) + + await store.send(.view(.deadlineTapped)) { + $0.modal = .init(kind: .deadline) + } + await store.send(.binding(.set(\.draftDeadline, updated))) { + $0.draftDeadline = updated + } + await store.send(.view(.deadlineSaved)) + await store.receive(\.async, .updateDeadline(room.id, updated)) { + $0.isLoading = true + } + let updatedRoom = ClassRoom( + id: room.id, + name: room.name, + joinCode: room.joinCode, + battle: room.battle, + deadline: updated, + memberCount: room.memberCount, + role: room.role, + status: room.status, + allowsAnonymousOpinion: room.allowsAnonymousOpinion, + requiresComment: room.requiresComment + ) + await store.receive(\.inner, .deadlineUpdated(.success(updatedRoom))) { + $0.isLoading = false + $0.room = updatedRoom + $0.deadline = updated + $0.modal = nil + } + } + + @Test + func 학생은_마감일과_삭제_동작을_사용할_수_없다() async { + let store = TestStore(initialState: ClassDetailFeature.State(room: ClassRoom.mocks[1])) { + ClassDetailFeature() + } + + await store.send(.view(.deadlineTapped)) + await store.send(.view(.deleteTapped)) + } + + @Test + func 배틀을_누르면_현재_클래스의_배틀을_전달한다() async { + let room = ClassRoom.mocks[0] + let store = TestStore(initialState: ClassDetailFeature.State(room: room)) { + ClassDetailFeature() + } + + await store.send(.view(.battleTapped)) + await store.receive(\.delegate, .openBattle(room.battle)) + } + + @Test + func 결과보기를_누르면_현재_클래스를_리포트로_전달한다() async { + let room = ClassRoom.mocks[0] + let store = TestStore(initialState: ClassDetailFeature.State(room: room)) { + ClassDetailFeature() + } + + await store.send(.view(.reportTapped)) + await store.receive(\.delegate, .openReport(room)) + } + + @Test + func 닉네임_수정_모달은_입력한_이름을_보존하고_닫힌다() async { + let store = TestStore(initialState: ClassDetailFeature.State(room: ClassRoom.mocks[1])) { + ClassDetailFeature() + } + + await store.send(.view(.nicknameTapped)) { + $0.modal = .init(kind: .nickname) + $0.draftNickname = "" + } + await store.send(.binding(.set(\.draftNickname, " 민지 "))) { + $0.draftNickname = " 민지 " + } + await store.send(.view(.nicknameSaved)) { + $0.modal = nil + $0.draftNickname = "민지" + } + } + + @Test + func 삭제가_완료된_뒤에만_화면을_닫는다() async { + let room = ClassRoom.mocks[0] + let store = TestStore(initialState: ClassDetailFeature.State(room: room)) { + ClassDetailFeature() + } + + await store.send(.view(.deleteTapped)) { + $0.customAlert = CustomAlertState( + title: "삭제 후에는 복구할 수 없어요.\n그럼에도 삭제하시겠습니까?", + confirmTitle: "삭제하기", + cancelTitle: "뒤로가기", + isDestructive: true, + style: .deleteConfirm + ) + } + await store.send(.view(.deleteConfirmed)) { + $0.customAlert = nil + } + await store.receive(\.async, .delete(room.id)) { + $0.isLoading = true + } + await store.receive(\.inner, .deleted(room.id, nil)) { + $0.isLoading = false + } + await store.receive(\.delegate, .deleted(room.id)) + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassJoinTests.swift b/Projects/Feature/Class/Tests/Sources/ClassJoinTests.swift new file mode 100644 index 00000000..a49edf93 --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassJoinTests.swift @@ -0,0 +1,122 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import Testing + +@MainActor +struct ClassJoinTests { + @Test + func 뒤로가기는_닫기_델리게이트를_보낸다() async { + let store = TestStore(initialState: ClassJoinFeature.State()) { + ClassJoinFeature() + } + + await store.send(.view(.backTapped)) + await store.receive(\.delegate.dismiss) + } + + @Test + func 참여코드를_조회하면_코드_모달을_닫기_위해_클래스를_전달한다() async { + let room = ClassRoom.mockJoinable + let store = TestStore(initialState: ClassJoinFeature.State()) { + ClassJoinFeature() + } withDependencies: { + $0.classUseCase = StubClassUseCase(fetchClass: { _ in room }) + } + + await store.send(.binding(.set(\.joinCode, " pk9t3s "))) { + $0.joinCode = " pk9t3s " + } + await store.send(.view(.findTapped)) + await store.receive(\.async, .find("PK9T3S")) { + $0.isLoading = true + } + await store.receive(\.inner, .found(.success(room))) { + $0.isLoading = false + } + await store.receive(\.delegate.found, room) + } + + @Test + func 이름을_입력하고_참여하면_클래스_참여_델리게이트를_보낸다() async { + let room = ClassRoom.mockJoinable + var state = ClassJoinFeature.State(mode: .nickname, preview: room) + state.nickname = " 민지 " + let store = TestStore(initialState: state) { + ClassJoinFeature() + } withDependencies: { + $0.classUseCase = StubClassUseCase(joinClass: { code, nickname in + #expect(code == room.joinCode) + #expect(nickname == "민지") + return room + }) + } + + await store.send(.view(.joinTapped)) + await store.receive(\.async, .join(joinCode: room.joinCode, nickname: "민지")) { + $0.isLoading = true + } + await store.receive(\.inner, .joined(.success(room))) { + $0.preview = nil + $0.isLoading = false + } + await store.receive(\.delegate.joined) + } + + @Test + func 잘못된_코드_조회는_오류를_상태에_저장한다() async { + let store = TestStore(initialState: ClassJoinFeature.State()) { + ClassJoinFeature() + } withDependencies: { + $0.classUseCase = StubClassUseCase(fetchClass: { _ in + throw ClassError.invalidCode + }) + } + + await store.send(.binding(.set(\.joinCode, "INVALID"))) { + $0.joinCode = "INVALID" + } + await store.send(.view(.findTapped)) + await store.receive(\.async, .find("INVALID")) { + $0.isLoading = true + } + await store.receive(\.inner, .found(.failure(.invalidCode))) { + $0.isLoading = false + $0.errorMessage = "참여 코드를 다시 확인해 주세요." + } + } +} + +private struct StubClassUseCase: ClassInterface { + var fetchClass: @Sendable (String) async throws -> ClassRoom = { _ in .mockJoinable } + var joinClass: @Sendable (String, String) async throws -> ClassRoom = { _, _ in .mockJoinable } + + func fetchMyClasses() async throws -> [ClassRoom] { + [] + } + + func fetchRecommendedBattles( + filter _: ClassTopicFilter + ) async throws -> [ClassBattleSummary] { + [] + } + + func createClass(_: ClassCreation) async throws -> ClassRoom { + .mockJoinable + } + + func fetchClass(joinCode: String) async throws -> ClassRoom { + try await fetchClass(joinCode) + } + + func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom { + try await joinClass(joinCode, nickname) + } + + func updateDeadline(id _: Int, deadline _: Date) async throws -> ClassRoom { + .mockJoinable + } + + func deleteClass(id _: Int) async throws {} +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassMemberTests.swift b/Projects/Feature/Class/Tests/Sources/ClassMemberTests.swift new file mode 100644 index 00000000..df51c48b --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassMemberTests.swift @@ -0,0 +1,124 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Testing + +@MainActor +struct ClassMemberTests { + @Test + func 멤버_내보내기는_서버_연동_전까지_상태를_변경하지_않는다() async { + let member = ClassMember(id: 2, name: "김민지") + let store = TestStore(initialState: ClassMemberFeature.State(room: ClassRoom.mocks[0], members: [member])) { + ClassMemberFeature() + } + let originalMembers = store.state.members + + await store.send(.view(.removeTapped(member.id))) + #expect(store.state.members == originalMembers) + } + + @Test + func 학생은_다른_멤버를_삭제할_수_없다() async { + let store = TestStore(initialState: ClassMemberFeature.State(room: ClassRoom.mocks[1])) { + ClassMemberFeature() + } + + await store.send(.view(.removeTapped(2))) + #expect(store.state.members.isEmpty) + #expect(store.state.room.memberCount == ClassRoom.mocks[1].memberCount) + } + + @Test + func 멤버_자료가_없으면_인원수만_보존하고_행은_만들지_않는다() { + let state = ClassMemberFeature.State(room: ClassRoom.mocks[0]) + + #expect(state.room.memberCount == ClassRoom.mocks[0].memberCount) + #expect(state.members.isEmpty) + #expect(state.visibleMembers.isEmpty) + #expect(state.viewState == .unavailable) + } + + @Test + func 검색어에_맞는_멤버만_표시한다() async { + let members = [ + ClassMember(id: 1, name: "김민지"), + ClassMember(id: 2, name: "서원지"), + ] + let store = TestStore(initialState: ClassMemberFeature.State(room: ClassRoom.mocks[0], members: members)) { + ClassMemberFeature() + } + + await store.send(.binding(.set(\.searchText, "김민지"))) { + $0.searchText = "김민지" + } + #expect(store.state.visibleMembers == [members[0]]) + #expect(store.state.viewState == .members([members[0]])) + + await store.send(.binding(.set(\.searchText, "없는 이름"))) { + $0.searchText = "없는 이름" + } + #expect(store.state.viewState == .noSearchResults) + } + + @Test + func 멤버_필터를_열고_선택한_조건을_적용한다() async { + let store = TestStore(initialState: ClassMemberFeature.State(room: ClassRoom.mocks[0])) { + ClassMemberFeature() + } + + await store.send(.view(.filterTapped)) { + $0.filter = .init() + } + await store.send( + .filter(.presented(.participationSelected(.completed))) + ) + await store.send(.filter(.presented(.applyTapped))) { + $0.filter = nil + } + #expect(store.state.appliedFilter.participation == .all) + } + + @Test + func 이름순_정렬은_전달된_멤버에_적용된다() async { + let members = [ + ClassMember(id: 1, name: "서원지"), + ClassMember(id: 2, name: "김민지"), + ] + let store = TestStore(initialState: ClassMemberFeature.State(room: ClassRoom.mocks[0], members: members)) { + ClassMemberFeature() + } + + await store.send(.view(.filterTapped)) { + $0.filter = .init() + } + await store.send(.filter(.presented(.sortSelected(.name)))) + await store.send(.filter(.presented(.applyTapped))) { + $0.filter = nil + } + #expect(store.state.visibleMembers == store.state.members.sorted { + $0.name.localizedStandardCompare($1.name) == .orderedAscending + }) + await store.send(.view(.filterTapped)) { + $0.filter = $0.appliedFilter + } + } + + @Test + func 지원하지_않는_필터와_정렬은_선택해도_적용되지_않는다() async { + let store = TestStore(initialState: ClassMemberFeature.State(room: ClassRoom.mocks[0])) { + ClassMemberFeature() + } + + await store.send(.view(.filterTapped)) { + $0.filter = .init() + } + await store.send(.filter(.presented(.participationSelected(.notParticipated)))) + await store.send(.filter(.presented(.completionSelected(.noComment)))) + await store.send(.filter(.presented(.attendanceSelected(.changed)))) + await store.send(.filter(.presented(.sortSelected(.comments)))) + await store.send(.filter(.presented(.applyTapped))) { + $0.filter = nil + } + #expect(store.state.appliedFilter == .init()) + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift b/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift new file mode 100644 index 00000000..c2edd07a --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift @@ -0,0 +1,157 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import Testing + +@MainActor +struct ClassOwnerDashboardTests { + @Test + func 기본_상태에는_예시_멤버와_의견이_없다() { + let state = ClassOwnerDashboardFeature.State(room: ClassRoom.mocks[0]) + #expect(state.members.isEmpty) + #expect(state.opinions.isEmpty) + #expect(state.visibleMembers.isEmpty) + #expect(state.visibleOpinions.isEmpty) + #expect(state.needsReviewViewState == .unavailable) + #expect(state.highParticipationViewState == .unavailable) + #expect(state.topOpinionViewState == .empty) + } + + @Test + func 참여_요약은_해당_멤버가_없으면_빈_상태로_표시한다() { + var state = ClassOwnerDashboardFeature.State(room: ClassRoom.mocks[0]) + state.members = [ + ClassOwnerMember(id: 1, name: "첫 멤버", participationCount: 1), + ClassOwnerMember(id: 2, name: "둘째 멤버", participationCount: 0), + ] + + #expect(state.needsReviewViewState == .empty) + #expect(state.highParticipationViewState == .loaded([state.members[0]])) + + state.members[0] = ClassOwnerMember(id: 1, name: "첫 멤버", participationCount: 0) + #expect(state.highParticipationViewState == .empty) + } + + @Test + func 멤버_정렬과_검색을_함께_적용한다() async { + let store = TestStore(initialState: ClassOwnerDashboardFeature.State.preview(room: ClassRoom.mocks[0])) { + ClassOwnerDashboardFeature() + } + + await store.send(.view(.memberSortSelected(.participation))) { + $0.memberSort = .participation + } + #expect(store.state.visibleMembers.prefix(3).map(\.participationCount) == [8, 7, 6]) + #expect(store.state.visibleMembers.filter { $0.participationCount > 0 }.count == 28) + + await store.send(.view(.searchTextChanged("김민지"))) { + $0.searchText = "김민지" + } + #expect(store.state.visibleMembers.map(\.name) == ["김민지"]) + } + + @Test + func 참여_상태_필터는_미참여_멤버만_표시한다() async { + let store = TestStore(initialState: ClassOwnerDashboardFeature.State.preview(room: ClassRoom.mocks[0])) { + ClassOwnerDashboardFeature() + } + + await store.send(.view(.memberFilterSelected(.notParticipated))) { + $0.memberFilter = .notParticipated + } + #expect(store.state.visibleMembers.count == 4) + #expect(store.state.visibleMembers.allSatisfy { $0.participationCount == 0 }) + } + + @Test + func 피드백_대상_멤버를_전달한다() async { + let store = TestStore(initialState: ClassOwnerDashboardFeature.State.preview(room: ClassRoom.mocks[0])) { + ClassOwnerDashboardFeature() + } + let member = store.state.members[0] + + await store.send(.view(.feedbackTapped(member.id))) + await store.receive(\.delegate, .openFeedback(member)) + + await store.send(.view(.memberTapped(member.id))) + await store.receive(\.delegate, .openMemberDetail(member)) + } + + @Test + func 의견_정렬은_인기순과_전달된_최신순을_구분한다() async { + var state = ClassOwnerDashboardFeature.State(room: ClassRoom.mocks[0]) + state.opinions = [ + ClassOwnerOpinion( + id: 1, + author: "첫 의견", + text: "A", + replyCount: 1, + recommendationCount: 1, + createdAt: Date(timeIntervalSince1970: 200) + ), + ClassOwnerOpinion( + id: 2, + author: "둘째 의견", + text: "B", + replyCount: 3, + recommendationCount: 3, + createdAt: Date(timeIntervalSince1970: 100) + ), + ] + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } + + #expect(store.state.visibleOpinions.map(\.id) == [2, 1]) + #expect(store.state.topOpinionViewState == .loaded([state.opinions[1], state.opinions[0]])) + await store.send(.view(.opinionFilterSelected(.latest))) { + $0.opinionFilter = .latest + } + #expect(store.state.visibleOpinions.map(\.id) == [1, 2]) + } + + @Test + func 정렬_기준이_없는_의견은_필터를_적용하지_않는다() async { + var state = ClassOwnerDashboardFeature.State(room: ClassRoom.mocks[0]) + state.opinions = [ClassOwnerOpinion(id: 1, author: "첫 의견", text: "A", replyCount: 2)] + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } + + #expect(!store.state.canSortOpinions(.popular)) + #expect(!store.state.canSortOpinions(.latest)) + #expect(store.state.activeOpinionFilter == nil) + #expect(store.state.topOpinionViewState == .unavailable) + await store.send(.view(.opinionFilterSelected(.latest))) + #expect(store.state.opinionFilter == .popular) + #expect(store.state.visibleOpinions.map(\.id) == [1]) + } + + #if DEBUG + @Test + func 미리보기_참여_수치와_멤버_목록이_같은_인원을_사용한다() { + let state = ClassOwnerDashboardFeature.State.preview(room: ClassRoom.mocks[0]) + let participated = state.members.filter { $0.participationCount > 0 }.count + + #expect(state.members.count == 32) + #expect(participated == 28) + #expect(state.previewMetrics?.optionAVoteCount == 18) + #expect(state.previewMetrics?.optionBVoteCount == 10) + #expect(state.previewMetrics?.commentAuthorCount == 24) + } + #endif + + @Test + func 서버_연동_전에는_피드백_등록을_전송하지_않는다() async { + let store = TestStore(initialState: ClassFeedbackComposeFeature.State( + room: ClassRoom.mocks[0], + memberName: "김민지" + )) { + ClassFeedbackComposeFeature() + } + + await store.send(.binding(.set(\.feedback, "좋은 의견이에요"))) { + $0.feedback = "좋은 의견이에요" + } + await store.send(.view(.submitTapped)) { + $0.showUnavailableAlert = true + } + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassOwnerDetailTests.swift b/Projects/Feature/Class/Tests/Sources/ClassOwnerDetailTests.swift new file mode 100644 index 00000000..4227431b --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassOwnerDetailTests.swift @@ -0,0 +1,58 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Testing + +@MainActor +struct ClassOwnerDetailTests { + @Test + func 멤버_상세는_전달받은_멤버를_표시하고_활동_기록을_추측하지_않는다() { + let member = ClassOwnerMember(id: 42, name: "공은지", participationCount: 1) + let state = ClassMemberDetailFeature.State(room: ClassRoom.mocks[0], member: member) + + #expect(state.member == member) + #expect(state.activity == nil) + } + + @Test + func 대댓글_상세는_선택한_의견을_유지하고_임의의_답글을_만들지_않는다() { + let opinion = ClassOwnerOpinion(id: 7, author: "공은지", text: "제 의견입니다.", replyCount: 3) + let state = ClassReplyDetailFeature.State(room: ClassRoom.mocks[0], opinion: opinion) + + #expect(state.opinion == opinion) + #expect(state.replies.isEmpty) + #expect(state.viewState == .unavailable) + } + + @Test + func 실제_답글이_없을_때만_빈_상태를_표시한다() { + let opinion = ClassOwnerOpinion(id: 8, author: "공은지", text: "제 의견입니다.", replyCount: 0) + let state = ClassReplyDetailFeature.State(room: ClassRoom.mocks[0], opinion: opinion) + + #expect(state.viewState == .empty) + } + + @Test + func 멤버_댓글을_누르면_선택한_내용을_전달한다() async { + let member = ClassOwnerMember(id: 42, name: "공은지", participationCount: 1) + let opinion = ClassOwnerOpinion(id: 101, author: "공은지", text: "제 의견입니다.", replyCount: 1) + let activity = ClassMemberDetailFeature.State.Activity( + initialStance: "찬성", + finalStance: "찬성", + commentCount: 1, + replyCount: 2, + receivedLikeCount: 0, + comment: "제 의견입니다.", + commentOpinion: opinion + ) + #expect(!activity.changedStance) + let store = TestStore(initialState: ClassMemberDetailFeature.State( + room: ClassRoom.mocks[0], member: member, activity: activity + )) { + ClassMemberDetailFeature() + } + + await store.send(.view(.commentTapped)) + await store.receive(\.delegate, .commentSelected(opinion)) + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassRecommendTests.swift b/Projects/Feature/Class/Tests/Sources/ClassRecommendTests.swift new file mode 100644 index 00000000..6b5c6e54 --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassRecommendTests.swift @@ -0,0 +1,70 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Testing + +@MainActor +struct ClassRecommendTests { + @Test + func 화면이_뜨면_조건에_맞는_추천_배틀을_불러온다() async { + let store = TestStore(initialState: ClassRecommendFeature.State(filter: .init(level: .middle))) { + ClassRecommendFeature() + } + + await store.send(.view(.onAppear)) + await store.receive(\.async, .fetch(.init(level: .middle))) { + $0.isLoading = true + } + await store.receive(\.inner, .battles(.success( + ClassBattleSummary.mocks.filter { $0.level == .middle } + ))) { + $0.isLoading = false + $0.battles = ClassBattleSummary.mocks.filter { $0.level == .middle } + } + } + + @Test + func 배틀을_선택하고_선택하기를_누르면_선택_델리게이트를_보낸다() async { + let battle = ClassBattleSummary.mocks[0] + var state = ClassRecommendFeature.State(filter: .init()) + state.battles = [battle] + let store = TestStore(initialState: state) { + ClassRecommendFeature() + } + + await store.send(.view(.battleTapped(battle.id))) { + $0.selectedBattleId = battle.id + } + await store.send(.view(.selectTapped)) + await store.receive(\.delegate.select, battle) + } + + @Test + func 선택한_배틀을_다시_누르면_선택이_해제된다() async { + var state = ClassRecommendFeature.State(filter: .init()) + state.battles = ClassBattleSummary.mocks + state.selectedBattleId = 101 + let store = TestStore(initialState: state) { + ClassRecommendFeature() + } + + await store.send(.view(.battleTapped(101))) { + $0.selectedBattleId = nil + } + await store.send(.view(.selectTapped)) + } + + @Test + func 로딩_중이고_배틀이_없으면_스켈레톤을_보여주고_실패하면_오류로_바꾼다() { + var state = ClassRecommendFeature.State(filter: .init()) + state.isLoading = true + #expect(state.viewState == .loading) + + state.isLoading = false + state.loadFailed = true + #expect(state.viewState == .error) + + state.battles = ClassBattleSummary.mocks + #expect(state.viewState == .loaded) + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift b/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift new file mode 100644 index 00000000..24b0cca7 --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift @@ -0,0 +1,43 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Testing + +@MainActor +struct ClassReportTests { + @Test + func 결과_탭을_선택하면_해당_내용으로_바뀐다() async { + let store = TestStore(initialState: ClassReportFeature.State(room: ClassRoom.mocks[0])) { + ClassReportFeature() + } + + await store.send(.view(.tabSelected(.classResult))) { + $0.selectedTab = .classResult + } + await store.send(.view(.tabSelected(.classResult))) + await store.send(.view(.tabSelected(.feedback))) { + $0.selectedTab = .feedback + } + } + + @Test + func 결과_뒤로가기는_코디네이터에_닫기를_알린다() async { + let store = TestStore(initialState: ClassReportFeature.State(room: ClassRoom.mocks[0])) { + ClassReportFeature() + } + + await store.send(.view(.backTapped)) + await store.receive(\.delegate.dismiss) + } + + #if DEBUG + @Test + func 리포트_예시_내용은_명시적인_미리보기_자료에_보관한다() { + let preview = ClassReportPreviewContent.figma + + #expect(preview[.participantCount] == "28 / 32") + #expect(preview[.teacherName] == "김민지 선생님") + #expect(preview.initialVoteLeading == 0.64) + } + #endif +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift b/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift new file mode 100644 index 00000000..6a0b70f0 --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift @@ -0,0 +1,51 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import Testing + +@MainActor +struct ClassSettingTests { + private let battle = ClassBattleSummary.mocks[0] + private let deadline = Date(timeIntervalSince1970: 1_800_000_000) + + @Test + func 클래스명이_비어있으면_만들기를_누를_수_없다() { + var state = ClassSettingFeature.State(battle: battle, deadline: deadline) + state.name = " " + #expect(state.canCreate == false) + + state.name = "2학년 3반" + #expect(state.canCreate) + } + + @Test + func 마감일을_끄면_마감일_없이_클래스를_만든다() { + var state = ClassSettingFeature.State(battle: battle, deadline: deadline) + state.name = " 2학년 3반 " + state.isDeadlineEnabled = false + + #expect(state.creation.name == "2학년 3반") + #expect(state.creation.deadline == .distantFuture) + #expect(state.creation.battleId == battle.id) + } + + @Test + func 클래스를_만들면_생성_델리게이트를_보낸다() async { + var state = ClassSettingFeature.State(battle: battle, deadline: deadline) + state.name = "2학년 3반" + let store = TestStore(initialState: state) { + ClassSettingFeature() + } + store.exhaustivity = .off + + await store.send(.view(.createTapped)) + await store.receive(\.async, .create(state.creation)) { + $0.isLoading = true + } + await store.receive(\.inner.created) { + $0.isLoading = false + } + await store.receive(\.delegate.created) + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassShareTests.swift b/Projects/Feature/Class/Tests/Sources/ClassShareTests.swift new file mode 100644 index 00000000..b70b7749 --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassShareTests.swift @@ -0,0 +1,45 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Foundation +import Testing + +@MainActor +struct ClassShareTests { + private let room = ClassRoom.mocks[0] + + @Test + func 공유_문구에_클래스명과_참여코드를_담는다() { + let state = ClassShareFeature.State(room: room) + #expect(state.shareMessage.contains(room.name)) + #expect(state.shareMessage.contains(room.joinCode)) + } + + @Test + func 마감일이_없는_클래스는_마감_문구를_숨긴다() { + let noDeadline = ClassRoom( + id: room.id, + name: room.name, + joinCode: room.joinCode, + battle: room.battle, + deadline: .distantFuture, + memberCount: room.memberCount, + role: room.role, + status: room.status, + allowsAnonymousOpinion: room.allowsAnonymousOpinion, + requiresComment: room.requiresComment + ) + #expect(ClassShareFeature.State(room: noDeadline).deadlineText == nil) + #expect(ClassShareFeature.State(room: room).deadlineText != nil) + } + + @Test + func 클래스로_이동하면_이동_델리게이트를_보낸다() async { + let store = TestStore(initialState: ClassShareFeature.State(room: room)) { + ClassShareFeature() + } + + await store.send(.view(.enterClassTapped)) + await store.receive(\.delegate.enterClass) + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassTests.swift b/Projects/Feature/Class/Tests/Sources/ClassTests.swift new file mode 100644 index 00000000..e9747923 --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassTests.swift @@ -0,0 +1,120 @@ +// +// ClassTests.swift +// Feature.ClassTests +// + +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Testing + +@MainActor +struct ClassTests { + @Test + func 참여하기_탭하면_코드_입력_모달을_띄운다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } + + await store.send(.view(.joinTapped)) { + $0.join = ClassJoinFeature.State() + } + } + + @Test + func 코드_확인에_성공하면_코드_모달을_닫고_이름_입력_모달을_띄운다() async { + let clock = TestClock() + let room = ClassRoom.mockJoinable + var state = ClassIntroFeature.State() + state.join = ClassJoinFeature.State() + let store = TestStore(initialState: state) { + ClassIntroFeature() + } withDependencies: { + $0.continuousClock = clock + } + + await store.send(.join(.presented(.delegate(.found(room))))) { + $0.join = nil + } + await clock.advance(by: .seconds(0.3)) + await store.receive(\.inner.nicknameModalPresented, room) { + $0.join = ClassJoinFeature.State(mode: .nickname, preview: room) + } + } + + @Test + func 이름_입력_모달_대기_중_뒤로가면_예약된_모달을_취소한다() async { + let clock = TestClock() + let room = ClassRoom.mockJoinable + var state = ClassIntroFeature.State() + state.join = ClassJoinFeature.State() + let store = TestStore(initialState: state) { + ClassIntroFeature() + } withDependencies: { + $0.continuousClock = clock + } + + await store.send(.join(.presented(.delegate(.found(room))))) { + $0.join = nil + } + await store.send(.view(.backTapped)) + await store.receive(\.delegate.backToHome) + await clock.advance(by: .seconds(0.3)) + } + + @Test + func 새_클래스_만들기_탭하면_생성_델리게이트를_보낸다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } + + await store.send(.view(.createTapped)) + await store.receive(\.delegate.create) + } + + @Test + func 이용권_등록_기능이_없으면_안내를_표시한다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } + + await store.send(.view(.ticketTapped)) { + $0.showTicketNotice = true + } + await store.send(.view(.ticketNoticeDismissed)) { + $0.showTicketNotice = false + } + } + + @Test + func 같은_카테고리를_다시_누르면_선택이_해제된다() async { + let store = TestStore(initialState: ClassTopicFeature.State()) { + ClassTopicFeature() + } + + await store.send(.view(.categoryTapped(.philosophy))) { + $0.category = .philosophy + } + await store.send(.view(.categoryTapped(.philosophy))) { + $0.category = nil + } + } + + @Test + func 콘텐츠_보기_탭하면_선택한_조건으로_검색_델리게이트를_보낸다() async { + var state = ClassTopicFeature.State() + state.keyword = " 촉법소년 " + state.level = .high + state.category = .society + let store = TestStore(initialState: state) { + ClassTopicFeature() + } + + await store.send(.view(.searchTapped)) + await store.receive(\.delegate.search, ClassTopicFilter( + keyword: "촉법소년", + level: .high, + category: .society + )) + } +} diff --git a/Projects/Feature/Class/Tests/Sources/MyClassTests.swift b/Projects/Feature/Class/Tests/Sources/MyClassTests.swift new file mode 100644 index 00000000..21a62ed5 --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/MyClassTests.swift @@ -0,0 +1,52 @@ +@testable import Class +import ClassDomainInterface +import ComposableArchitecture +import Testing + +@MainActor +struct MyClassTests { + @Test + func 진행_상태로_필터링한다() async { + var state = MyClassFeature.State() + state.rooms = ClassRoom.mocks + let store = TestStore(initialState: state) { + MyClassFeature() + } + + await store.send(.view(.progressTapped(.closed))) { + $0.progress = .closed + } + #expect(store.state.visibleRooms.isEmpty) + + await store.send(.view(.progressTapped(.open))) { + $0.progress = .open + } + #expect(store.state.visibleRooms.map(\.id) == [1, 2]) + } + + @Test + func 클래스를_누르면_해당_방을_델리게이트로_전달한다() async { + var state = MyClassFeature.State() + state.rooms = ClassRoom.mocks + let store = TestStore(initialState: state) { + MyClassFeature() + } + + await store.send(.view(.roomTapped(1))) + await store.receive(\.delegate, .openRoom(ClassRoom.mocks[0])) + } + + @Test + func 로딩_중이고_클래스가_없으면_스켈레톤을_보여주고_실패하면_오류로_바꾼다() { + var state = MyClassFeature.State() + state.isLoading = true + #expect(state.viewState == .loading) + + state.isLoading = false + state.errorMessage = "error" + #expect(state.viewState == .error) + + state.rooms = ClassRoom.mocks + #expect(state.viewState == .loaded(ClassRoom.mocks)) + } +} diff --git a/Projects/Feature/FeatureAssembly/Project.swift b/Projects/Feature/FeatureAssembly/Project.swift index c143a097..0fa3b727 100644 --- a/Projects/Feature/FeatureAssembly/Project.swift +++ b/Projects/Feature/FeatureAssembly/Project.swift @@ -19,6 +19,7 @@ let project = Project.makeModule( .feature(.hifi, .implementation), .feature(.battle, .implementation), .feature(.profile, .implementation), + .feature(.classroom, .implementation), .feature(.notification, .implementation), .feature(.ad, .implementation), .feature(.featureSharedUI, .implementation), diff --git a/Projects/Feature/FeatureAssembly/Sources/Exported/FeatureAssemblyExported.swift b/Projects/Feature/FeatureAssembly/Sources/Exported/FeatureAssemblyExported.swift index 31e1d7d3..04cbe83e 100644 --- a/Projects/Feature/FeatureAssembly/Sources/Exported/FeatureAssemblyExported.swift +++ b/Projects/Feature/FeatureAssembly/Sources/Exported/FeatureAssemblyExported.swift @@ -12,6 +12,7 @@ @_exported import Auth @_exported import Battle @_exported import Chat +@_exported import Class @_exported import Hifi @_exported import Home @_exported import Notification diff --git a/Projects/Feature/Home/Sources/Attendance/View/AttendanceModalView.swift b/Projects/Feature/Home/Sources/Attendance/View/AttendanceModalView.swift index b78fbfe7..ff8df784 100644 --- a/Projects/Feature/Home/Sources/Attendance/View/AttendanceModalView.swift +++ b/Projects/Feature/Home/Sources/Attendance/View/AttendanceModalView.swift @@ -3,9 +3,8 @@ // Home // -import SwiftUI - import ComposableArchitecture +import SwiftUI @ViewAction(for: AttendanceModalFeature.self) public struct AttendanceModalView: View { @@ -27,7 +26,7 @@ public struct AttendanceModalView: View { extension AttendanceModalView { @ViewBuilder private func dimmedBackground() -> some View { - Color.black.opacity(0.4) + Color.clear .ignoresSafeArea() .contentShape(Rectangle()) .onTapGesture { send(.dismissTapped) } diff --git a/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift index c588c93e..67c2b8c9 100644 --- a/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift +++ b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift @@ -24,6 +24,7 @@ public enum AnalyticsButton: String, Sendable { case tabHome = "tab_home" case tabExplore = "tab_explore" case tabQuickBattle = "tab_quick_battle" + case tabClass = "tab_class" case tabMypage = "tab_mypage" // 댓글 case commentShare = "comment_share" diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classIntroBackground.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classIntroBackground.imageset/Contents.json new file mode 100644 index 00000000..35a13f23 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classIntroBackground.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classIntroBackground.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classIntroBackground.imageset/classIntroBackground.png b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classIntroBackground.imageset/classIntroBackground.png new file mode 100644 index 00000000..2675e626 Binary files /dev/null and b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classIntroBackground.imageset/classIntroBackground.png differ diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classJoin.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classJoin.imageset/Contents.json new file mode 100644 index 00000000..447e75cf --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classJoin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classJoin.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classJoin.imageset/classJoin.png b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classJoin.imageset/classJoin.png new file mode 100644 index 00000000..fb3a09d4 Binary files /dev/null and b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classJoin.imageset/classJoin.png differ diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classMine.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classMine.imageset/Contents.json new file mode 100644 index 00000000..0e422596 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classMine.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "classMine.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classMine.imageset/classMine.png b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classMine.imageset/classMine.png new file mode 100644 index 00000000..3f52eb32 Binary files /dev/null and b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Class/classMine.imageset/classMine.png differ diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClass.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClass.imageset/Contents.json new file mode 100644 index 00000000..106f074a --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClass.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "tabClass.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClass.imageset/tabClass.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClass.imageset/tabClass.svg new file mode 100644 index 00000000..165e5a01 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClass.imageset/tabClass.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClassActive.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClassActive.imageset/Contents.json new file mode 100644 index 00000000..4081c1c5 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClassActive.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "tabClassActive.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClassActive.imageset/tabClassActive.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClassActive.imageset/tabClassActive.svg new file mode 100644 index 00000000..864de85d --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/GNB/tabClassActive.imageset/tabClassActive.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift b/Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift index dcb0871f..82af4909 100644 --- a/Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift +++ b/Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift @@ -35,6 +35,8 @@ public enum ImageAsset: String { case tabExploreActive case tabQuickBattle case tabQuickBattleActive + case tabClass + case tabClassActive case tabMyPage case tabMyPageActive @@ -59,4 +61,10 @@ public enum ImageAsset: String { // MARK: - 댓글(관점) case heartPlus + + // MARK: - 클래스 + + case classIntroBackground + case classJoin + case classMine } diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Badge/PickePill.swift b/Projects/UI/PickeDesignKit/Sources/UI/Badge/PickePill.swift new file mode 100644 index 00000000..0d78e5a1 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Badge/PickePill.swift @@ -0,0 +1,18 @@ +// +// PickePill.swift +// PickeDesignKit +// + +import SwiftUI + +public extension View { + /// 캡슐형 조건 뱃지. primary50 채움 + primary500 텍스트. + func pickePill() -> some View { + pretendardFont(.semiBold12) + .foregroundStyle(.primary500) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.primary50, in: Capsule()) + } +} diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Checkbox/PickeCheckbox.swift b/Projects/UI/PickeDesignKit/Sources/UI/Checkbox/PickeCheckbox.swift new file mode 100644 index 00000000..978c2002 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Checkbox/PickeCheckbox.swift @@ -0,0 +1,30 @@ +// +// PickeCheckbox.swift +// PickeDesignKit +// + +import SwiftUI + +public extension View { + /// 원형 체크박스. 체크 아이콘 뷰에 붙이면 선택 시에만 아이콘이 보인다. + func pickeCheckbox( + isChecked: Bool, + size: CGFloat = 20 + ) -> some View { + pretendardFont(.bold10) + .foregroundStyle(.beige50) + .opacity(isChecked ? 1 : 0) + .frame(width: size, height: size) + .background { + Circle() + .fill(isChecked ? ComponentToken.Checkbox.Background.selected : ComponentToken.Checkbox.Background.default) + } + .overlay { + Circle() + .stroke( + isChecked ? ComponentToken.Checkbox.Border.selected : ComponentToken.Checkbox.Border.default, + lineWidth: 1 + ) + } + } +} diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Chip/PickeChoiceChip.swift b/Projects/UI/PickeDesignKit/Sources/UI/Chip/PickeChoiceChip.swift new file mode 100644 index 00000000..d33e3232 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Chip/PickeChoiceChip.swift @@ -0,0 +1,22 @@ +// +// PickeChoiceChip.swift +// PickeDesignKit +// + +import SwiftUI + +public extension View { + /// 그리드 단일 선택 칩. 선택 시 primary50 채움 + primary500 테두리, 미선택 시 beige 카드. + func pickeChoiceChip(isSelected: Bool) -> some View { + pretendardFont(isSelected ? .headingSmall : .bodyMedium) + .foregroundStyle(isSelected ? .primary500 : .gray300) + .lineLimit(1) + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentShape(Rectangle()) + .pickeCard( + isSelected ? .primary50 : .beige50, + border: isSelected ? .primary500 : .beige600 + ) + } +} diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Input/PickeTextField.swift b/Projects/UI/PickeDesignKit/Sources/UI/Input/PickeTextField.swift new file mode 100644 index 00000000..35bcb660 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Input/PickeTextField.swift @@ -0,0 +1,18 @@ +// +// PickeTextField.swift +// PickeDesignKit +// + +import SwiftUI + +public extension View { + /// 한 줄 입력 필드. beige50 배경 + beige600 테두리. 플레이스홀더는 `prompt:` 로 넘긴 Text 에 gray300 을 준다. + func pickeTextField(height: CGFloat = 44) -> some View { + pretendardFont(.labelMedium) + .foregroundStyle(.gray800) + .padding(.horizontal, 8) + .frame(height: height) + .frame(maxWidth: .infinity) + .pickeCard(.beige50, border: .beige600) + } +} diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeBoxSegment.swift b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeBoxSegment.swift new file mode 100644 index 00000000..85339d6a --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeBoxSegment.swift @@ -0,0 +1,39 @@ +// +// PickeBoxSegment.swift +// PickeDesignKit +// + +import SwiftUI + +public extension View { + /// 박스형 세그먼트의 한 칸. 선택 시 beige50 박스 + primary500 텍스트. + /// `namespace` 를 넘기면 선택 박스가 칸 사이를 미끄러지듯 이동한다. + func pickeBoxSegment( + isSelected: Bool, + namespace: Namespace.ID? = nil + ) -> some View { + pretendardFont(isSelected ? .headingSmall : .bodyMedium) + .foregroundStyle(isSelected ? .primary500 : .gray300) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background { + if isSelected { + if let namespace { + RoundedRectangle(cornerRadius: .radiusDefault) + .fill(.beige50) + .matchedGeometryEffect(id: "pickeBoxSegment", in: namespace) + } else { + RoundedRectangle(cornerRadius: .radiusDefault) + .fill(.beige50) + } + } + } + .contentShape(Rectangle()) + } + + /// 박스형 세그먼트 칸들을 감싸는 gray50 트랙. + func pickeBoxSegmentTrack() -> some View { + padding(4) + .roundedBackground(.gray50) + } +} diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Toggle/PickeToggleStyle.swift b/Projects/UI/PickeDesignKit/Sources/UI/Toggle/PickeToggleStyle.swift new file mode 100644 index 00000000..57e5d511 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Sources/UI/Toggle/PickeToggleStyle.swift @@ -0,0 +1,33 @@ +// +// PickeToggleStyle.swift +// PickeDesignKit +// + +import SwiftUI + +/// 32x18 스위치. `Toggle(...).toggleStyle(.picke)` 로 쓴다. +public struct PickeToggleStyle: ToggleStyle { + public func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 16) { + configuration.label + Spacer(minLength: 0) + Capsule() + .fill(configuration.isOn ? .toggleTrackOn : .toggleTrackOff) + .frame(width: 32, height: 18) + .overlay(alignment: configuration.isOn ? .trailing : .leading) { + Circle() + .fill(.toggleThumbDefault) + .frame(width: 14, height: 14) + .padding(2) + } + .animation(.easeInOut(duration: 0.15), value: configuration.isOn) + .onTapGesture { configuration.isOn.toggle() } + } + } +} + +public extension ToggleStyle where Self == PickeToggleStyle { + static var picke: PickeToggleStyle { + .init() + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Modal/PickeModal.swift b/Projects/UI/PickeSharedUI/Sources/Modal/PickeModal.swift index b38abbbf..ae9bfeb3 100644 --- a/Projects/UI/PickeSharedUI/Sources/Modal/PickeModal.swift +++ b/Projects/UI/PickeSharedUI/Sources/Modal/PickeModal.swift @@ -3,27 +3,42 @@ // DesignSystem // -import SwiftUI - -import PickeDesignKit - import ComposableArchitecture +import PickeDesignKit +import SwiftUI public extension View { /// 하단에서 슬라이드업 되는 커스텀 모달(바텀시트) 오버레이. + /// 딤은 제자리에서 페이드되고, 시트만 아래에서 올라온다. /// - Parameters: /// - store: `@Presents` 스코프 바인딩 (nil 이면 미표시). + /// - dimOpacity: 딤 배경 불투명도. /// - content: 표시할 모달 뷰 (스코프된 스토어를 받음). func pickeModal( _ store: Binding?>, + dimOpacity: Double = 0.4, @ViewBuilder content: @escaping (Store) -> some View ) -> some View { overlay { - if let modalStore = store.wrappedValue { - content(modalStore) - .transition(.move(edge: .bottom).combined(with: .opacity)) + ZStack { + if store.wrappedValue != nil { + Color.black.opacity(dimOpacity) + .ignoresSafeArea() + .allowsHitTesting(false) + .transition(.opacity.animation(.easeInOut(duration: 0.2))) + } + if let modalStore = store.wrappedValue { + content(modalStore) + .transition(.move(edge: .bottom)) + } } } - .animation(.easeInOut(duration: 0.3), value: store.wrappedValue != nil) + .animation( + .spring( + response: 0.36, + dampingFraction: 0.86 + ), + value: store.wrappedValue != nil + ) } } diff --git a/Projects/UI/PickeSharedUI/Sources/TabBar/PickeTabBarAppearance.swift b/Projects/UI/PickeSharedUI/Sources/TabBar/PickeTabBarAppearance.swift new file mode 100644 index 00000000..ec699129 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/TabBar/PickeTabBarAppearance.swift @@ -0,0 +1,70 @@ +// +// PickeTabBarAppearance.swift +// PickeSharedUI +// + +import UIKit + +import PickeDesignKit + +/// 앱 전역 UITabBar 외형 (배경·구분선·아이템 색/폰트). +public enum PickeTabBarAppearance { + public static func configure() { + let selectedColor = UIColor.neutral900 + let normalColor = UIColor.gray200 + let backgroundColor = UIColor.bgDefault + let borderColor = UIColor.borderDefault.withAlphaComponent(0.4) + let font = UIFont.pretendardFontFamily(family: .Medium, size: 12) + + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = backgroundColor + appearance.shadowColor = borderColor + appearance.selectionIndicatorImage = UIImage() + + configureItemAppearance( + appearance.stackedLayoutAppearance, + selectedColor, + normalColor, + font + ) + configureItemAppearance( + appearance.inlineLayoutAppearance, + selectedColor, + normalColor, + font + ) + configureItemAppearance( + appearance.compactInlineLayoutAppearance, + selectedColor, + normalColor, + font + ) + + UITabBar.appearance().standardAppearance = appearance + UITabBar.appearance().scrollEdgeAppearance = appearance + UITabBar.appearance().tintColor = selectedColor + UITabBar.appearance().unselectedItemTintColor = normalColor + } +} + +private extension PickeTabBarAppearance { + static func configureItemAppearance( + _ itemAppearance: UITabBarItemAppearance, + _ selectedColor: UIColor, + _ normalColor: UIColor, + _ font: UIFont + ) { + itemAppearance.normal.iconColor = normalColor + itemAppearance.normal.titleTextAttributes = [ + .font: font, + .foregroundColor: normalColor, + ] + + itemAppearance.selected.iconColor = selectedColor + itemAppearance.selected.titleTextAttributes = [ + .font: font, + .foregroundColor: selectedColor, + ] + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/TabBar/PickeTabItemLabel.swift b/Projects/UI/PickeSharedUI/Sources/TabBar/PickeTabItemLabel.swift new file mode 100644 index 00000000..24f0fdbd --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/TabBar/PickeTabItemLabel.swift @@ -0,0 +1,39 @@ +// +// PickeTabItemLabel.swift +// PickeSharedUI +// + +import SwiftUI + +import PickeDesignKit + +/// 탭바 아이템 라벨 — 타이틀 + 24pt 템플릿 아이콘. +public struct PickeTabItemLabel: View { + private let title: String + private let icon: ImageAsset + private let tag: Int + + public init( + title: String, + icon: ImageAsset, + tag: Int + ) { + self.title = title + self.icon = icon + self.tag = tag + } + + public var body: some View { + Label { + Text(title) + .pretendardFont(.labelSmall) + } icon: { + Image(asset: icon) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + .accessibilityIdentifier("tab.\(tag)") + } +} diff --git a/README.md b/README.md index 2b5cb0a6..b6aee3ed 100644 --- a/README.md +++ b/README.md @@ -662,7 +662,7 @@ SVG 생성에는 Graphviz의 `dot`이 필요합니다. 앱 빌드나 Tuist 캐 | 저장소 | PickeStorage, Keychain, SQLiteData | | 이미지 | SDWebImageSwiftUI, Kingfisher | | 모니터링 | Firebase Crashlytics, Sentry | -| 분석·광고 | Mixpanel, Google Mobile Ads, Kakao AdFit | +| 분석·광고 | Mixpanel, Sentry, Google Mobile Ads, Kakao AdFit | | 테스트 | Swift Testing, XCTest, Tuist | 패키지 선언과 실제 해석된 버전은 [Tuist/Package.swift](Tuist/Package.swift)와 [Tuist/Package.resolved](Tuist/Package.resolved)를 기준으로 합니다. diff --git a/fastlane/metadata/ko/release_notes.txt b/fastlane/metadata/ko/release_notes.txt index cd697db5..87f7f22c 100644 --- a/fastlane/metadata/ko/release_notes.txt +++ b/fastlane/metadata/ko/release_notes.txt @@ -1,3 +1,3 @@ -[V 1.0.9] +[V 1.1.1] - 버그 수정 - 전반적인 UI 정리 및 안정성 개선