diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift index 4b3dc741..cadf4db5 100644 --- a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift @@ -14,7 +14,6 @@ import TCAFlow @FlowCoordinator(screen: "ClassScreen", navigation: true) public struct ClassCoordinator { - @Dependency(\.classMemberUseCase) private var classMemberUseCase @Dependency(\.classUseCase) private var classUseCase @Dependency(\.uuid) private var uuid @Dependency(\.date) private var date @@ -365,9 +364,9 @@ private extension ClassCoordinator { id: routeID, action: .members(.loadingStarted(requestID)) ))) - let fetch: Effect = .run { [classMemberUseCase] send in + let fetch: Effect = .run { [classUseCase] send in do { - let memberList = try await classMemberUseCase.fetchMembers(classID: roomID) + let memberList = try await classUseCase.fetchMembers(classID: roomID) await send(.inner(.membersLoaded( routeID: routeID, roomID: roomID, @@ -835,10 +834,10 @@ private extension ClassCoordinator { guard memberID != currentMemberID else { return .send(.inner(.memberMutationFailed(roomID, "본인은 내보낼 수 없어요."))) } - return .run { [classMemberUseCase, classUseCase] send in + return .run { [classUseCase] send in do { - try await classMemberUseCase.removeMember(classID: roomID, memberID: memberID) - let memberList = try await classMemberUseCase.fetchMembers(classID: roomID) + try await classUseCase.removeMember(classID: roomID, memberID: memberID) + let memberList = try await classUseCase.fetchMembers(classID: roomID) let room = try await classUseCase.fetchClass(id: roomID) await send(.inner(.memberMutationCompleted(room, memberList.members))) } catch { @@ -858,10 +857,10 @@ private extension ClassCoordinator { guard memberID == currentMemberID else { return .send(.inner(.memberMutationFailed(roomID, "본인 이름만 바꿀 수 있어요."))) } - return .run { [classMemberUseCase, classUseCase] send in + return .run { [classUseCase] send in do { - _ = try await classMemberUseCase.updateMyDisplayName(classID: roomID, name: name) - let memberList = try await classMemberUseCase.fetchMembers(classID: roomID) + _ = try await classUseCase.updateMyDisplayName(classID: roomID, name: name) + let memberList = try await classUseCase.fetchMembers(classID: roomID) let room = try await classUseCase.fetchClass(id: roomID) await send(.inner(.memberMutationCompleted(room, memberList.members))) } catch { diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift index aeb9bea9..2601e820 100644 --- a/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift @@ -114,7 +114,6 @@ public struct ClassCoordinatorView: View { } } } - .toolbar(.hidden, for: .tabBar) } } diff --git a/Projects/App/Sources/Navigation/MainTab/MainTabBar.swift b/Projects/App/Sources/Navigation/MainTab/MainTabBar.swift deleted file mode 100644 index faa9d18c..00000000 --- a/Projects/App/Sources/Navigation/MainTab/MainTabBar.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// MainTabBar.swift -// Picke -// -// Created by Picke on 10/17/26. -// - -import ComposableArchitecture -import PickeDesignKit -import SwiftUI - -struct MainTabBar: View { - @Bindable var store: StoreOf - - var body: some View { - tabButtons() - .padding(.horizontal, 21) - .background { - Color.white - .ignoresSafeArea(.container, edges: .bottom) - .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: -2) - } - } -} - -extension MainTabBar { - @ViewBuilder - private func tabButtons() -> some View { - HStack(spacing: 0) { - ForEach(MainTabCoordinator.Tab.allCases, id: \.rawValue) { tab in - tabButton(tab) - } - } - } - - private func tabButton(_ tab: MainTabCoordinator.Tab) -> some View { - Button { - store.send(.tabTapped(tab.rawValue)) - } label: { - tabLabel(tab) - } - .buttonStyle(.plain) - .accessibilityLabel(tab.title) - .accessibilityAddTraits(store.state.isSelected(tab) ? .isSelected : []) - .accessibilityIdentifier("tab.\(tab.rawValue)") - } - - @ViewBuilder - private func tabLabel(_ tab: MainTabCoordinator.Tab) -> some View { - VStack(spacing: 8) { - Image(asset: tab.iconAsset(isSelected: store.state.isSelected(tab))) - .renderingMode(.original) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .accessibilityHidden(true) - - Text(tab.title) - .pretendardFont(.labelSmall) - .foregroundStyle(store.state.isSelected(tab) ? .neutral900 : .gray200) - .lineLimit(1) - .frame(height: 16) - } - .padding(.top, 12) - .padding(.bottom, 1) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - } -} diff --git a/Projects/App/Sources/Navigation/MainTab/MainTabView.swift b/Projects/App/Sources/Navigation/MainTab/MainTabView.swift index 932270bc..a8aca3d5 100644 --- a/Projects/App/Sources/Navigation/MainTab/MainTabView.swift +++ b/Projects/App/Sources/Navigation/MainTab/MainTabView.swift @@ -15,7 +15,6 @@ import TCAFlow public struct MainTabView: View { @Bindable private var store: StoreOf - @State private var hasMeasuredKeyboard = false public init(store: StoreOf) { PickeTabBarAppearance.configure() @@ -24,29 +23,18 @@ public struct MainTabView: View { public var body: some View { ZStack { - tabLayout() + tabRouter MainTabKeyboardObserver { isVisible in if store.isKeyboardVisible != isVisible { store.send(.keyboardVisibilityChanged(isVisible)) } - hasMeasuredKeyboard = true } } } } private extension MainTabView { - @ViewBuilder - func tabLayout() -> some View { - VStack(spacing: 0) { - tabRouter - if hasMeasuredKeyboard, !store.shouldHideTabBar { - MainTabBar(store: store) - } - } - } - var tabRouter: some View { TCAFlowTabRouter( selectedTab: $store.selectedTab.sending(\.selectTab), @@ -65,10 +53,9 @@ private extension MainTabView { } ) { tabContent(for: $0) - .toolbar(.hidden, for: .tabBar) + .toolbar(store.shouldHideTabBar ? .hidden : .visible, for: .tabBar) } .tint(.neutral900) - .toolbar(.hidden, for: .tabBar) } @ViewBuilder diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIChatContext.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIChat/ClassAIChatContext.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIChatContext.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIChat/ClassAIChatContext.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIChatMessage.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIChat/ClassAIChatMessage.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIChatMessage.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIChat/ClassAIChatMessage.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackContext.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackContext.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackContext.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackContext.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackEvidence.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackEvidence.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackEvidence.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackEvidence.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackKind.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackKind.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackKind.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackKind.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackPoint.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackPoint.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackPoint.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackPoint.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackResult.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackResult.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackResult.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackResult.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackVoteChoice.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackVoteChoice.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackVoteChoice.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIFeedbackVoteChoice.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIInsight.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIInsight.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIInsight.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIInsight.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIPersonalFeedback.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIPersonalFeedback.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleDetail.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleDetail.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleDetail.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleDetail.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleOption.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleOption.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleOption.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleOption.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleSession.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleSession.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleStep.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleStep.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleStep.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleStep.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleSummary.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSummary.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleSummary.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSummary.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleType.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleType.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleType.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleType.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwnerDashboardData.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerDashboardData.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwnerDashboardData.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassOwner/ClassOwnerDashboardData.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassDebate.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Debate/ClassDebate.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassDebate.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Debate/ClassDebate.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassDebateMessage.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Debate/ClassDebateMessage.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassDebateMessage.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Debate/ClassDebateMessage.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassDebateSpeaker.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Debate/ClassDebateSpeaker.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassDebateSpeaker.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Debate/ClassDebateSpeaker.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassDebateStatus.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Debate/ClassDebateStatus.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassDebateStatus.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Debate/ClassDebateStatus.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIChatError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassAIChatError.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIChatError.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassAIChatError.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassAIFeedbackError.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAIFeedbackError.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassAIFeedbackError.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassBattleError.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassError.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTicketError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassTicketError.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTicketError.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassTicketError.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectiveAuthor.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectiveAuthor.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectiveAuthor.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectiveAuthor.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectiveItem.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectiveItem.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectiveItem.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectiveItem.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectiveOption.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectiveOption.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectiveOption.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectiveOption.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectivePage.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectivePage.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectivePage.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectivePage.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectiveReceipt.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectiveReceipt.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassPerspectiveReceipt.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Perspective/ClassPerspectiveReceipt.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassParticipationMetrics.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassParticipationMetrics.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassParticipationMetrics.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassParticipationMetrics.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReport.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReport.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReport.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReport.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportLayout.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportLayout.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportLayout.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportLayout.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportOpinion.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportOpinion.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportOpinion.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportOpinion.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportOpinionKind.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportOpinionKind.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportOpinionKind.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportOpinionKind.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportReplyContext.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportReplyContext.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportReplyContext.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportReplyContext.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportTab.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportTab.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassReportTab.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Report/ClassReportTab.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassResult.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResult.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassResult.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResult.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassResultOption.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultOption.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassResultOption.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultOption.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassResultStats.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassResultStats.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAudienceLevel.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassAudienceLevel.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassAudienceLevel.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassAudienceLevel.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCategory.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCategory.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCategory.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCategory.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCreation.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift similarity index 81% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCreation.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift index 0f9cfbe6..c026d7e0 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassCreation.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift @@ -15,6 +15,7 @@ public struct ClassCreation: Equatable, Sendable { public var allowsAnonymousOpinion: Bool public var requiresComment: Bool public var ownerParticipates: Bool + public var ownerDisplayName: String? public init( name: String, @@ -22,7 +23,8 @@ public struct ClassCreation: Equatable, Sendable { battleId: Int, allowsAnonymousOpinion: Bool, requiresComment: Bool, - ownerParticipates: Bool = false + ownerParticipates: Bool = false, + ownerDisplayName: String? = nil ) { self.name = name self.deadline = deadline @@ -31,6 +33,7 @@ public struct ClassCreation: Equatable, Sendable { self.allowsAnonymousOpinion = allowsAnonymousOpinion self.requiresComment = requiresComment self.ownerParticipates = ownerParticipates + self.ownerDisplayName = ownerDisplayName } public init( @@ -39,7 +42,8 @@ public struct ClassCreation: Equatable, Sendable { topicVersionId: Int, allowsAnonymousOpinion: Bool, requiresComment: Bool, - ownerParticipates: Bool = false + ownerParticipates: Bool = false, + ownerDisplayName: String? = nil ) { self.name = name self.deadline = deadline @@ -48,5 +52,6 @@ public struct ClassCreation: Equatable, Sendable { self.allowsAnonymousOpinion = allowsAnonymousOpinion self.requiresComment = requiresComment self.ownerParticipates = ownerParticipates + self.ownerDisplayName = ownerDisplayName } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassJoinPreview.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassJoinPreview.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassJoinPreview.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassJoinPreview.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRole.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassRole.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRole.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassRole.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRoom.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassRoom.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassRoom.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassRoom.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassStatus.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassStatus.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassStatus.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassStatus.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTicket.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Ticket/ClassTicket.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTicket.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Ticket/ClassTicket.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTicketPreview.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Ticket/ClassTicketPreview.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTicketPreview.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Ticket/ClassTicketPreview.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicCandidate.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicCandidate.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicCandidate.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicCandidate.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicDraft.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicDraft.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicDraft.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicDraft.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicFilter.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicFilter.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicFilter.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicFilter.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicValidation.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicValidation.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicValidation.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicValidation.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicVersion.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicVersion.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassTopicVersion.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Topic/ClassTopicVersion.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteChoice.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteChoice.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteChoice.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteChoice.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteHistory.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteHistory.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteReceipt.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteReceipt.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteReceipt.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteReceipt.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteStats.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteStats.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteStats.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteStats.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteStatsOption.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteStatsOption.swift similarity index 100% rename from Projects/Domain/ClassDomain/Interface/Sources/Entity/ClassVoteStatsOption.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteStatsOption.swift diff --git a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassBattleInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift similarity index 67% rename from Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassBattleInterface.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift index c659b3d1..c78b435e 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassBattleInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift @@ -1,23 +1,21 @@ +// +// ClassBattleInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + import ComposableArchitecture import Foundation public protocol ClassBattleInterface: Sendable { func fetchBattle(classID: Int) async throws -> ClassBattleSession - func submitPreVote( - classID: Int, - optionID: Int - ) async throws -> ClassVoteReceipt + func submitPreVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt func completeTTS(classID: Int) async throws - func submitPostVote( - classID: Int, - optionID: Int - ) async throws -> ClassVoteReceipt + func submitPostVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt func fetchMyVotes(classID: Int) async throws -> ClassVoteHistory func fetchVoteStats(classID: Int) async throws -> ClassVoteStats - func publishPerspective( - classID: Int, - content: String - ) async throws -> ClassPerspectiveReceipt + func publishPerspective(classID: Int, content: String) async throws -> ClassPerspectiveReceipt func fetchPerspectives( classID: Int, cursor: String?, @@ -29,11 +27,15 @@ public protocol ClassBattleInterface: Sendable { } public enum ClassBattleRepositoryDependency: TestDependencyKey { - public static var testValue: any ClassBattleInterface { UnavailableClassBattleRepository() } + public static var testValue: any ClassBattleInterface { + MockClassRepository() + } } public enum ClassBattleUseCaseDependency: TestDependencyKey { - public static var testValue: any ClassBattleInterface { UnavailableClassBattleRepository() } + public static var testValue: any ClassBattleInterface { + MockClassRepository() + } } public extension DependencyValues { diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassAIRepositoryInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassAIRepositoryInterface.swift deleted file mode 100644 index 8c6d07d2..00000000 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassAIRepositoryInterface.swift +++ /dev/null @@ -1,51 +0,0 @@ -import ComposableArchitecture -import Foundation - -public protocol ClassAIRepositoryInterface: Sendable { - func fetchDebate(classID: Int) async throws -> ClassDebate - func startDebate(classID: Int) async throws -> ClassDebate - func submitDebateReply( - classID: Int, - round: Int, - content: String, - requestKey: UUID - ) async throws -> ClassDebate - func retryDebate(classID: Int) async throws -> ClassDebate -} - -public enum ClassAIRepositoryDependency: TestDependencyKey { - public static var testValue: any ClassAIRepositoryInterface { - UnimplementedClassAIRepository() - } -} - -public extension DependencyValues { - var classAIRepository: any ClassAIRepositoryInterface { - get { self[ClassAIRepositoryDependency.self] } - set { self[ClassAIRepositoryDependency.self] = newValue } - } -} - -private struct UnimplementedClassAIRepository: ClassAIRepositoryInterface { - func fetchDebate(classID _: Int) async throws -> ClassDebate { - throw ClassAIChatError.noSession - } - - func startDebate(classID _: Int) async throws -> ClassDebate { - throw ClassAIChatError.noSession - } - - func submitDebateReply( - classID _: Int, - round _: Int, - content _: String, - requestKey _: UUID - ) async throws -> ClassDebate { - throw ClassAIChatError.noSession - } - - func retryDebate(classID _: Int) async throws -> ClassDebate { - throw ClassAIChatError.noSession - } - -} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassInterface.swift index ef97d9b3..61788835 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassInterface.swift @@ -6,29 +6,18 @@ // 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(id: Int) async throws -> ClassRoom - func fetchClass(joinCode: String) async throws -> ClassRoom - func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview - func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom - func updateDeadline(id: Int, deadline: Date) async throws -> ClassRoom - func deleteClass(id: Int) async throws - func leaveClass(id: Int) async throws - func fetchReport(roomID: Int) async throws -> ClassReport? - func fetchOwnerDashboard(roomID: Int) async throws -> ClassOwnerDashboardData? -} - -public extension ClassInterface { - func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview { - let room = try await fetchClass(joinCode: joinCode) - return ClassJoinPreview(room: room) - } -} +public protocol ClassInterface: + ClassRoomInterface, + ClassRecommendationInterface, + ClassBattleInterface, + ClassMemberInterface, + ClassDebateInterface, + ClassTicketInterface, + ClassResultInterface, + ClassTopicDraftInterface, + ClassReportInterface, + ClassLocalAIInterface {} public enum ClassRepositoryDependency: TestDependencyKey { public static var testValue: any ClassInterface { diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Debate/ClassDebateInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Debate/ClassDebateInterface.swift new file mode 100644 index 00000000..976657b6 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Debate/ClassDebateInterface.swift @@ -0,0 +1,45 @@ +// +// ClassDebateInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + +import ComposableArchitecture +import Foundation + +public protocol ClassDebateInterface: Sendable { + func fetchDebate(classID: Int) async throws -> ClassDebate + func startDebate(classID: Int) async throws -> ClassDebate + func submitDebateReply( + classID: Int, + round: Int, + content: String, + requestKey: UUID + ) async throws -> ClassDebate + func retryDebate(classID: Int) async throws -> ClassDebate +} + +public enum ClassDebateRepositoryDependency: TestDependencyKey { + public static var testValue: any ClassDebateInterface { + MockClassRepository() + } +} + +public enum ClassDebateUseCaseDependency: TestDependencyKey { + public static var testValue: any ClassDebateInterface { + MockClassRepository() + } +} + +public extension DependencyValues { + var classDebateRepository: any ClassDebateInterface { + get { self[ClassDebateRepositoryDependency.self] } + set { self[ClassDebateRepositoryDependency.self] = newValue } + } + + var classDebateUseCase: any ClassDebateInterface { + get { self[ClassDebateUseCaseDependency.self] } + set { self[ClassDebateUseCaseDependency.self] = newValue } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/Default/MockClassAIChatUseCase.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassAIChatUseCase.swift similarity index 95% rename from Projects/Domain/ClassDomain/Interface/Sources/UseCase/Default/MockClassAIChatUseCase.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassAIChatUseCase.swift index b2df93c1..599947e1 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/Default/MockClassAIChatUseCase.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassAIChatUseCase.swift @@ -7,7 +7,7 @@ import Foundation -public struct MockClassAIChatUseCase: ClassAIChatInterface { +public struct MockClassAIChatUseCase { public var startHandler: @Sendable (UUID, ClassAIChatContext) async throws -> Void public var respondHandler: @Sendable (UUID, String, Bool) async throws -> String public var endHandler: @Sendable (UUID) async -> Void diff --git a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/Default/MockClassAIFeedbackUseCase.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassAIFeedbackUseCase.swift similarity index 89% rename from Projects/Domain/ClassDomain/Interface/Sources/UseCase/Default/MockClassAIFeedbackUseCase.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassAIFeedbackUseCase.swift index 9b34f723..bdfca59b 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/Default/MockClassAIFeedbackUseCase.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassAIFeedbackUseCase.swift @@ -5,7 +5,7 @@ // Created by Picke on 10/12/26. // -public struct MockClassAIFeedbackUseCase: ClassAIFeedbackInterface { +public struct MockClassAIFeedbackUseCase { public var generateHandler: @Sendable (ClassAIFeedbackContext) async throws -> ClassAIFeedbackResult public init( diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift new file mode 100644 index 00000000..ee0404ce --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift @@ -0,0 +1,158 @@ +import Foundation + +public extension MockClassRepository { + public func fetchBattle(classID: Int) async throws -> ClassBattleSession { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchBattle") + } + + public func submitPreVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: submitPreVote") + } + + public func completeTTS(classID: Int) async throws { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: completeTTS") + } + + public func submitPostVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: submitPostVote") + } + + public func fetchMyVotes(classID: Int) async throws -> ClassVoteHistory { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchMyVotes") + } + + public func fetchVoteStats(classID: Int) async throws -> ClassVoteStats { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchVoteStats") + } + + public func publishPerspective(classID: Int, content: String) async throws -> ClassPerspectiveReceipt { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: publishPerspective") + } + + public func fetchPerspectives( + classID: Int, + cursor: String?, + size: Int?, + optionID: Int?, + sort: String? + ) async throws -> ClassPerspectivePage { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchPerspectives") + } + + public func fetchMyPerspectives(classID: Int) async throws -> [ClassPerspectiveItem] { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchMyPerspectives") + } + + public func fetchMembers(classID: Int) async throws -> ClassMemberList { + let members = try await fetchMembers(roomID: classID) + let currentID = try await currentMemberID(roomID: classID) + return ClassMemberList(members: members, currentMemberID: currentID) + } + + public func updateMyDisplayName(classID: Int, name: String) async throws -> ClassMember { + let currentID = try await currentMemberID(roomID: classID) + return try await updateDisplayName( + roomID: classID, + memberID: currentID, + currentMemberID: currentID, + name: name + ) + } + + public func removeMember(classID: Int, memberID: Int) async throws { + let currentID = try await currentMemberID(roomID: classID) + _ = try await removeMember(roomID: classID, memberID: memberID, currentMemberID: currentID) + } + + public func fetchDebate(classID: Int) async throws -> ClassDebate { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchDebate") + } + + public func startDebate(classID: Int) async throws -> ClassDebate { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: startDebate") + } + + public func submitDebateReply( + classID: Int, + round: Int, + content: String, + requestKey: UUID + ) async throws -> ClassDebate { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: submitDebateReply") + } + + public func retryDebate(classID: Int) async throws -> ClassDebate { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: retryDebate") + } + + public func previewTicket(code: String) async throws -> ClassTicketPreview { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: previewTicket") + } + + public func registerTicket(code: String) async throws -> ClassTicket { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: registerTicket") + } + + public func fetchMyTicket() async throws -> ClassTicket { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchMyTicket") + } + + public func fetchResult(roomID: Int) async throws -> ClassResult { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchResult") + } + + public func fetchPersonalResult(roomID: Int) async throws -> ClassAIPersonalFeedback { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchPersonalResult") + } + + public func fetchInsight(roomID: Int) async throws -> ClassAIInsight { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchInsight") + } + + public func retryInsight(roomID: Int) async throws { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: retryInsight") + } + + public func createTopicDraft(filter: ClassTopicFilter) async throws -> ClassTopicDraft { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: createTopicDraft") + } + + public func fetchTopicDraft(id: Int) async throws -> ClassTopicDraft { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchTopicDraft") + } + + public func suggestTopicDraft(id: Int, filter: ClassTopicFilter?) async throws -> ClassTopicDraft { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: suggestTopicDraft") + } + + public func selectTopicDraft(draftID: Int, batchNo: Int, candidateID: Int) async throws -> ClassTopicVersion { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: selectTopicDraft") + } + + public func validateTopicDraft( + draftID: Int, + baseVersionID: Int, + candidate: ClassTopicCandidate + ) async throws -> ClassTopicValidation { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: validateTopicDraft") + } + + public func startAIChat(_ id: UUID, _ context: ClassAIChatContext) async throws { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: startAIChat") + } + + public func respondAIChat(_ id: UUID, _ text: String, isFinalRound: Bool) async throws -> String { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: respondAIChat") + } + + public func endAIChat(_ id: UUID) async { + return + } + + public func generateAIFeedback(context: ClassAIFeedbackContext) async throws -> ClassAIFeedbackResult { + if let feedbackHandler { + return try await feedbackHandler(context) + } + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: generateAIFeedback") + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository.swift index c7e529d8..2e7f95ef 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository.swift @@ -42,13 +42,15 @@ public actor MockClassRepository: ClassInterface { private var reports: [Int: ClassReport] private var dashboards: [Int: ClassOwnerDashboardData] private var nextID: Int + let feedbackHandler: (@Sendable (ClassAIFeedbackContext) async throws -> ClassAIFeedbackResult)? public init( rooms: [ClassRoom] = ClassRoom.mocks + [.mockClosedSociety], availableRooms: [ClassRoom] = [ClassRoom.mockJoinable], reports: [Int: ClassReport]? = nil, dashboards: [Int: ClassOwnerDashboardData]? = nil, - members: [Int: [ClassMember]]? = nil + members: [Int: [ClassMember]]? = nil, + generateAIFeedback: (@Sendable (ClassAIFeedbackContext) async throws -> ClassAIFeedbackResult)? = nil ) { let initialRooms = rooms + availableRooms let initialIDs = Set(initialRooms.map(\.id)) @@ -93,6 +95,7 @@ public actor MockClassRepository: ClassInterface { (room.id, (memberLists[room.id]?.map(\.id).max() ?? 0) + 1) }) nextID = (rooms + availableRooms).map(\.id).max().map { $0 + 1 } ?? 1 + feedbackHandler = generateAIFeedback } public func fetchMembers(roomID: Int) async throws -> [ClassMember] { @@ -246,7 +249,11 @@ public actor MockClassRepository: ClassInterface { rooms.append(room) reports[id] = nil dashboards[id] = nil - membersByRoom[id] = [ClassMember(id: 1, name: "운영자", isOwner: true)] + membersByRoom[id] = [ClassMember( + id: 1, + name: creation.ownerParticipates ? (creation.ownerDisplayName ?? "운영자") : "운영자", + isOwner: true + )] currentMemberIDs[id] = 1 nextMemberIDs[id] = 2 return room @@ -340,6 +347,40 @@ public actor MockClassRepository: ClassInterface { return updated } + public func updateName(id: Int, name: String) 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("클래스 관리자만 이름을 변경할 수 있습니다.") + } + guard room.status == .open else { throw ClassError.closed } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= 20 else { + throw ClassError.unknown("클래스 이름은 1~20자여야 합니다.") + } + let updated = ClassRoom( + id: room.id, + name: trimmed, + joinCode: room.joinCode, + battle: room.battle, + deadline: room.deadline, + closeAt: room.closeAt, + memberCount: room.memberCount, + role: room.role, + status: room.status, + allowsAnonymousOpinion: room.allowsAnonymousOpinion, + requiresComment: room.requiresComment, + ownerParticipates: room.ownerParticipates, + memberCapacity: room.memberCapacity, + battleType: room.battleType, + ticketExpiresAt: room.ticketExpiresAt + ) + 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 diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/UnavailableClassBattleRepository.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/UnavailableClassBattleRepository.swift deleted file mode 100644 index 01d9779a..00000000 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/UnavailableClassBattleRepository.swift +++ /dev/null @@ -1,28 +0,0 @@ -public struct UnavailableClassBattleRepository: ClassBattleInterface { - public init() {} - - public func fetchBattle(classID _: Int) async throws -> ClassBattleSession { throw ClassBattleError.invalidResponse } - public func submitPreVote( - classID _: Int, - optionID _: Int - ) async throws -> ClassVoteReceipt { throw ClassBattleError.invalidResponse } - public func completeTTS(classID _: Int) async throws { throw ClassBattleError.invalidResponse } - public func submitPostVote( - classID _: Int, - optionID _: Int - ) async throws -> ClassVoteReceipt { throw ClassBattleError.invalidResponse } - public func fetchMyVotes(classID _: Int) async throws -> ClassVoteHistory { throw ClassBattleError.invalidResponse } - public func fetchVoteStats(classID _: Int) async throws -> ClassVoteStats { throw ClassBattleError.invalidResponse } - public func publishPerspective( - classID _: Int, - content _: String - ) async throws -> ClassPerspectiveReceipt { throw ClassBattleError.invalidResponse } - public func fetchPerspectives( - classID _: Int, - cursor _: String?, - size _: Int?, - optionID _: Int?, - sort _: String? - ) async throws -> ClassPerspectivePage { throw ClassBattleError.invalidResponse } - public func fetchMyPerspectives(classID _: Int) async throws -> [ClassPerspectiveItem] { throw ClassBattleError.invalidResponse } -} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/LocalAI/ClassLocalAIInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/LocalAI/ClassLocalAIInterface.swift new file mode 100644 index 00000000..e01e5d1b --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/LocalAI/ClassLocalAIInterface.swift @@ -0,0 +1,40 @@ +// +// ClassLocalAIInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + +import ComposableArchitecture +import Foundation + +public protocol ClassLocalAIInterface: Sendable { + func startAIChat(_ id: UUID, _ context: ClassAIChatContext) async throws + func respondAIChat(_ id: UUID, _ text: String, isFinalRound: Bool) async throws -> String + func endAIChat(_ id: UUID) async + func generateAIFeedback(context: ClassAIFeedbackContext) async throws -> ClassAIFeedbackResult +} + +public enum ClassLocalAIRepositoryDependency: TestDependencyKey { + public static var testValue: any ClassLocalAIInterface { + MockClassRepository() + } +} + +public enum ClassLocalAIUseCaseDependency: TestDependencyKey { + public static var testValue: any ClassLocalAIInterface { + MockClassRepository() + } +} + +public extension DependencyValues { + var classLocalAIRepository: any ClassLocalAIInterface { + get { self[ClassLocalAIRepositoryDependency.self] } + set { self[ClassLocalAIRepositoryDependency.self] = newValue } + } + + var classLocalAIUseCase: any ClassLocalAIInterface { + get { self[ClassLocalAIUseCaseDependency.self] } + set { self[ClassLocalAIUseCaseDependency.self] = newValue } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassMemberInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Member/ClassMemberInterface.swift similarity index 60% rename from Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassMemberInterface.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Repository/Member/ClassMemberInterface.swift index 0c6c00cd..21c1c53c 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/ClassMemberInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Member/ClassMemberInterface.swift @@ -1,3 +1,10 @@ +// +// ClassMemberInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + import ComposableArchitecture import Foundation @@ -9,13 +16,13 @@ public protocol ClassMemberInterface: Sendable { public enum ClassMemberRepositoryDependency: TestDependencyKey { public static var testValue: any ClassMemberInterface { - UnimplementedClassMemberInterface() + MockClassRepository() } } public enum ClassMemberUseCaseDependency: TestDependencyKey { public static var testValue: any ClassMemberInterface { - UnimplementedClassMemberInterface() + MockClassRepository() } } @@ -30,17 +37,3 @@ public extension DependencyValues { set { self[ClassMemberUseCaseDependency.self] = newValue } } } - -private struct UnimplementedClassMemberInterface: ClassMemberInterface { - func fetchMembers(classID _: Int) async throws -> ClassMemberList { - throw ClassError.unknown("멤버 데이터를 불러올 수 없어요.") - } - - func updateMyDisplayName(classID _: Int, name _: String) async throws -> ClassMember { - throw ClassError.unknown("이름 변경을 사용할 수 없어요.") - } - - func removeMember(classID _: Int, memberID _: Int) async throws { - throw ClassError.unknown("멤버 변경을 사용할 수 없어요.") - } -} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Recommendation/ClassRecommendationInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Recommendation/ClassRecommendationInterface.swift new file mode 100644 index 00000000..34818387 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Recommendation/ClassRecommendationInterface.swift @@ -0,0 +1,37 @@ +// +// ClassRecommendationInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + +import ComposableArchitecture +import Foundation + +public protocol ClassRecommendationInterface: Sendable { + func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] +} + +public enum ClassRecommendationRepositoryDependency: TestDependencyKey { + public static var testValue: any ClassRecommendationInterface { + MockClassRepository() + } +} + +public enum ClassRecommendationUseCaseDependency: TestDependencyKey { + public static var testValue: any ClassRecommendationInterface { + MockClassRepository() + } +} + +public extension DependencyValues { + var classRecommendationRepository: any ClassRecommendationInterface { + get { self[ClassRecommendationRepositoryDependency.self] } + set { self[ClassRecommendationRepositoryDependency.self] = newValue } + } + + var classRecommendationUseCase: any ClassRecommendationInterface { + get { self[ClassRecommendationUseCaseDependency.self] } + set { self[ClassRecommendationUseCaseDependency.self] = newValue } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Report/ClassReportInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Report/ClassReportInterface.swift new file mode 100644 index 00000000..585ff0ab --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Report/ClassReportInterface.swift @@ -0,0 +1,38 @@ +// +// ClassReportInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + +import ComposableArchitecture +import Foundation + +public protocol ClassReportInterface: Sendable { + func fetchReport(roomID: Int) async throws -> ClassReport? + func fetchOwnerDashboard(roomID: Int) async throws -> ClassOwnerDashboardData? +} + +public enum ClassReportRepositoryDependency: TestDependencyKey { + public static var testValue: any ClassReportInterface { + MockClassRepository() + } +} + +public enum ClassReportUseCaseDependency: TestDependencyKey { + public static var testValue: any ClassReportInterface { + MockClassRepository() + } +} + +public extension DependencyValues { + var classReportRepository: any ClassReportInterface { + get { self[ClassReportRepositoryDependency.self] } + set { self[ClassReportRepositoryDependency.self] = newValue } + } + + var classReportUseCase: any ClassReportInterface { + get { self[ClassReportUseCaseDependency.self] } + set { self[ClassReportUseCaseDependency.self] = newValue } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassResultInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift similarity index 51% rename from Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassResultInterface.swift rename to Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift index c678f963..dac4b067 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassResultInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift @@ -1,21 +1,29 @@ +// +// ClassResultInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + import ComposableArchitecture +import Foundation public protocol ClassResultInterface: Sendable { - func fetch(roomID: Int) async throws -> ClassResult - func fetchPersonal(roomID: Int) async throws -> ClassAIPersonalFeedback + func fetchResult(roomID: Int) async throws -> ClassResult + func fetchPersonalResult(roomID: Int) async throws -> ClassAIPersonalFeedback func fetchInsight(roomID: Int) async throws -> ClassAIInsight func retryInsight(roomID: Int) async throws } -public enum ClassResultUseCaseDependency: TestDependencyKey { +public enum ClassResultRepositoryDependency: TestDependencyKey { public static var testValue: any ClassResultInterface { - UnimplementedClassResultUseCase() + MockClassRepository() } } -public enum ClassResultRepositoryDependency: TestDependencyKey { +public enum ClassResultUseCaseDependency: TestDependencyKey { public static var testValue: any ClassResultInterface { - UnimplementedClassResultUseCase() + MockClassRepository() } } @@ -30,21 +38,3 @@ public extension DependencyValues { set { self[ClassResultUseCaseDependency.self] = newValue } } } - -private struct UnimplementedClassResultUseCase: ClassResultInterface { - func fetch(roomID _: Int) async throws -> ClassResult { - throw ClassError.network("클래스 결과 API가 연결되지 않았습니다.") - } - - func fetchPersonal(roomID _: Int) async throws -> ClassAIPersonalFeedback { - throw ClassAIFeedbackError.temporarilyUnavailable - } - - func fetchInsight(roomID _: Int) async throws -> ClassAIInsight { - throw ClassAIFeedbackError.temporarilyUnavailable - } - - func retryInsight(roomID _: Int) async throws { - throw ClassAIFeedbackError.temporarilyUnavailable - } -} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift new file mode 100644 index 00000000..748b4666 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift @@ -0,0 +1,57 @@ +// +// ClassRoomInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + +import ComposableArchitecture +import Foundation + +public protocol ClassRoomInterface: Sendable { + func fetchMyClasses() async throws -> [ClassRoom] + func createClass(_ creation: ClassCreation) async throws -> ClassRoom + func fetchClass(id: Int) async throws -> ClassRoom + func fetchClass(joinCode: String) async throws -> ClassRoom + func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview + func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom + func updateDeadline(id: Int, deadline: Date) async throws -> ClassRoom + func updateName(id: Int, name: String) async throws -> ClassRoom + func deleteClass(id: Int) async throws + func leaveClass(id: Int) async throws +} + +public extension ClassRoomInterface { + func updateName(id _: Int, name _: String) async throws -> ClassRoom { + throw ClassError.unknown("클래스 이름 변경을 지원하지 않습니다.") + } + + func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview { + let room = try await fetchClass(joinCode: joinCode) + return ClassJoinPreview(room: room) + } +} + +public enum ClassRoomRepositoryDependency: TestDependencyKey { + public static var testValue: any ClassRoomInterface { + MockClassRepository() + } +} + +public enum ClassRoomUseCaseDependency: TestDependencyKey { + public static var testValue: any ClassRoomInterface { + MockClassRepository() + } +} + +public extension DependencyValues { + var classRoomRepository: any ClassRoomInterface { + get { self[ClassRoomRepositoryDependency.self] } + set { self[ClassRoomRepositoryDependency.self] = newValue } + } + + var classRoomUseCase: any ClassRoomInterface { + get { self[ClassRoomUseCaseDependency.self] } + set { self[ClassRoomUseCaseDependency.self] = newValue } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Ticket/ClassTicketInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Ticket/ClassTicketInterface.swift new file mode 100644 index 00000000..0af86942 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Ticket/ClassTicketInterface.swift @@ -0,0 +1,39 @@ +// +// ClassTicketInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + +import ComposableArchitecture +import Foundation + +public protocol ClassTicketInterface: Sendable { + func previewTicket(code: String) async throws -> ClassTicketPreview + func registerTicket(code: String) async throws -> ClassTicket + func fetchMyTicket() async throws -> ClassTicket +} + +public enum ClassTicketRepositoryDependency: TestDependencyKey { + public static var testValue: any ClassTicketInterface { + MockClassRepository() + } +} + +public enum ClassTicketUseCaseDependency: TestDependencyKey { + public static var testValue: any ClassTicketInterface { + MockClassRepository() + } +} + +public extension DependencyValues { + var classTicketRepository: any ClassTicketInterface { + get { self[ClassTicketRepositoryDependency.self] } + set { self[ClassTicketRepositoryDependency.self] = newValue } + } + + var classTicketUseCase: any ClassTicketInterface { + get { self[ClassTicketUseCaseDependency.self] } + set { self[ClassTicketUseCaseDependency.self] = newValue } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/TopicDraft/ClassTopicDraftInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/TopicDraft/ClassTopicDraftInterface.swift new file mode 100644 index 00000000..f392eda3 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/TopicDraft/ClassTopicDraftInterface.swift @@ -0,0 +1,45 @@ +// +// ClassTopicDraftInterface.swift +// ClassDomainInterface +// +// Created by Picke on 10/26/26. +// + +import ComposableArchitecture +import Foundation + +public protocol ClassTopicDraftInterface: Sendable { + func createTopicDraft(filter: ClassTopicFilter) async throws -> ClassTopicDraft + func fetchTopicDraft(id: Int) async throws -> ClassTopicDraft + func suggestTopicDraft(id: Int, filter: ClassTopicFilter?) async throws -> ClassTopicDraft + func selectTopicDraft(draftID: Int, batchNo: Int, candidateID: Int) async throws -> ClassTopicVersion + func validateTopicDraft( + draftID: Int, + baseVersionID: Int, + candidate: ClassTopicCandidate + ) async throws -> ClassTopicValidation +} + +public enum ClassTopicDraftRepositoryDependency: TestDependencyKey { + public static var testValue: any ClassTopicDraftInterface { + MockClassRepository() + } +} + +public enum ClassTopicDraftUseCaseDependency: TestDependencyKey { + public static var testValue: any ClassTopicDraftInterface { + MockClassRepository() + } +} + +public extension DependencyValues { + var classTopicDraftRepository: any ClassTopicDraftInterface { + get { self[ClassTopicDraftRepositoryDependency.self] } + set { self[ClassTopicDraftRepositoryDependency.self] = newValue } + } + + var classTopicDraftUseCase: any ClassTopicDraftInterface { + get { self[ClassTopicDraftUseCaseDependency.self] } + set { self[ClassTopicDraftUseCaseDependency.self] = newValue } + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassAIChatInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassAIChatInterface.swift deleted file mode 100644 index eea02991..00000000 --- a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassAIChatInterface.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// ClassAIChatInterface.swift -// ClassDomainInterface -// -// Created by Picke on 10/11/26. -// - -import ComposableArchitecture -import Foundation - -public protocol ClassAIChatInterface: Sendable { - func start(_ id: UUID, _ context: ClassAIChatContext) async throws - /// `isFinalRound` 가 참이면 질문 없이 논점을 정리하며 토론을 마무리하는 답변을 만든다. - func respond( - _ id: UUID, - _ text: String, - isFinalRound: Bool - ) async throws -> String - func end(_ id: UUID) async -} - -public enum ClassAIChatUseCaseDependency: TestDependencyKey { - public static var testValue: any ClassAIChatInterface { - MockClassAIChatUseCase() - } - - public static var previewValue: any ClassAIChatInterface { - MockClassAIChatUseCase() - } -} - -public extension DependencyValues { - var classAIChatUseCase: any ClassAIChatInterface { - get { self[ClassAIChatUseCaseDependency.self] } - set { self[ClassAIChatUseCaseDependency.self] = newValue } - } -} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassAIFeedbackInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassAIFeedbackInterface.swift deleted file mode 100644 index 46153ad6..00000000 --- a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassAIFeedbackInterface.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// ClassAIFeedbackInterface.swift -// ClassDomainInterface -// -// Created by Picke on 10/12/26. -// - -import ComposableArchitecture - -public protocol ClassAIFeedbackInterface: Sendable { - func generate(context: ClassAIFeedbackContext) async throws -> ClassAIFeedbackResult -} - -public enum ClassAIFeedbackUseCaseDependency: TestDependencyKey { - public static var testValue: any ClassAIFeedbackInterface { - MockClassAIFeedbackUseCase() - } - - public static var previewValue: any ClassAIFeedbackInterface { - MockClassAIFeedbackUseCase() - } -} - -public extension DependencyValues { - var classAIFeedbackUseCase: any ClassAIFeedbackInterface { - get { self[ClassAIFeedbackUseCaseDependency.self] } - set { self[ClassAIFeedbackUseCaseDependency.self] = newValue } - } -} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassDebateInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassDebateInterface.swift deleted file mode 100644 index dcb052c0..00000000 --- a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassDebateInterface.swift +++ /dev/null @@ -1,50 +0,0 @@ -import ComposableArchitecture -import Foundation - -public protocol ClassDebateInterface: Sendable { - func fetch(classID: Int) async throws -> ClassDebate - func start(classID: Int) async throws -> ClassDebate - func submitReply( - classID: Int, - round: Int, - content: String, - requestKey: UUID - ) async throws -> ClassDebate - func retry(classID: Int) async throws -> ClassDebate -} - -public enum ClassDebateUseCaseDependency: TestDependencyKey { - public static var testValue: any ClassDebateInterface { - UnimplementedClassDebateUseCase() - } -} - -public extension DependencyValues { - var classDebateUseCase: any ClassDebateInterface { - get { self[ClassDebateUseCaseDependency.self] } - set { self[ClassDebateUseCaseDependency.self] = newValue } - } -} - -private struct UnimplementedClassDebateUseCase: ClassDebateInterface { - func fetch(classID _: Int) async throws -> ClassDebate { - throw ClassAIChatError.unavailable - } - - func start(classID _: Int) async throws -> ClassDebate { - throw ClassAIChatError.unavailable - } - - func submitReply( - classID _: Int, - round _: Int, - content _: String, - requestKey _: UUID - ) async throws -> ClassDebate { - throw ClassAIChatError.unavailable - } - - func retry(classID _: Int) async throws -> ClassDebate { - throw ClassAIChatError.unavailable - } -} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassTicketInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassTicketInterface.swift deleted file mode 100644 index 76ceb5aa..00000000 --- a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassTicketInterface.swift +++ /dev/null @@ -1,42 +0,0 @@ -import ComposableArchitecture -import Foundation - -public protocol ClassTicketInterface: Sendable { - func preview(code: String) async throws -> ClassTicketPreview - func register(code: String) async throws -> ClassTicket - func fetchMyTicket() async throws -> ClassTicket -} - -public enum ClassTicketRepositoryDependency: TestDependencyKey { - public static var testValue: any ClassTicketInterface { UnimplementedClassTicketInterface() } -} - -public enum ClassTicketUseCaseDependency: TestDependencyKey { - public static var testValue: any ClassTicketInterface { UnimplementedClassTicketInterface() } -} - -private struct UnimplementedClassTicketInterface: ClassTicketInterface { - func preview(code _: String) async throws -> ClassTicketPreview { - throw ClassTicketError.network("이용권 코드 확인을 사용할 수 없어요.") - } - - func register(code _: String) async throws -> ClassTicket { - throw ClassTicketError.network("이용권 등록을 사용할 수 없어요.") - } - - func fetchMyTicket() async throws -> ClassTicket { - throw ClassTicketError.network("내 이용권 조회를 사용할 수 없어요.") - } -} - -public extension DependencyValues { - var classTicketRepository: any ClassTicketInterface { - get { self[ClassTicketRepositoryDependency.self] } - set { self[ClassTicketRepositoryDependency.self] = newValue } - } - - var classTicketUseCase: any ClassTicketInterface { - get { self[ClassTicketUseCaseDependency.self] } - set { self[ClassTicketUseCaseDependency.self] = newValue } - } -} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassTopicDraftInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassTopicDraftInterface.swift deleted file mode 100644 index 24a3edc4..00000000 --- a/Projects/Domain/ClassDomain/Interface/Sources/UseCase/ClassTopicDraftInterface.swift +++ /dev/null @@ -1,62 +0,0 @@ -import ComposableArchitecture -import Foundation - -public protocol ClassTopicDraftInterface: Sendable { - func create(filter: ClassTopicFilter) async throws -> ClassTopicDraft - func fetch(id: Int) async throws -> ClassTopicDraft - func suggest( - id: Int, - filter: ClassTopicFilter? - ) async throws -> ClassTopicDraft - func select( - draftID: Int, - batchNo: Int, - candidateID: Int - ) async throws -> ClassTopicVersion - func validate( - draftID: Int, - baseVersionID: Int, - candidate: ClassTopicCandidate - ) async throws -> ClassTopicValidation -} - -public enum ClassTopicDraftRepositoryDependency: TestDependencyKey { - public static var testValue: any ClassTopicDraftInterface { UnimplementedClassTopicDraft() } -} - -public enum ClassTopicDraftUseCaseDependency: TestDependencyKey { - public static var testValue: any ClassTopicDraftInterface { UnimplementedClassTopicDraft() } -} - -public extension DependencyValues { - var classTopicDraftRepository: any ClassTopicDraftInterface { - get { self[ClassTopicDraftRepositoryDependency.self] } - set { self[ClassTopicDraftRepositoryDependency.self] = newValue } - } - - var classTopicDraftUseCase: any ClassTopicDraftInterface { - get { self[ClassTopicDraftUseCaseDependency.self] } - set { self[ClassTopicDraftUseCaseDependency.self] = newValue } - } -} - -private struct UnimplementedClassTopicDraft: ClassTopicDraftInterface { - func create(filter: ClassTopicFilter) async throws -> ClassTopicDraft { throw ClassError.unknown("주제 초안 미설정") } - func fetch(id: Int) async throws -> ClassTopicDraft { throw ClassError.unknown("주제 초안 미설정") } - func suggest( - id: Int, - filter: ClassTopicFilter? - ) async throws -> ClassTopicDraft { throw ClassError.unknown("주제 초안 미설정") } - - func select( - draftID: Int, - batchNo: Int, - candidateID: Int - ) async throws -> ClassTopicVersion { throw ClassError.unknown("주제 초안 미설정") } - - func validate( - draftID: Int, - baseVersionID: Int, - candidate: ClassTopicCandidate - ) async throws -> ClassTopicValidation { throw ClassError.unknown("주제 초안 미설정") } -} diff --git a/Projects/Domain/ClassDomain/Project.swift b/Projects/Domain/ClassDomain/Project.swift index d39905aa..a2fd2068 100644 --- a/Projects/Domain/ClassDomain/Project.swift +++ b/Projects/Domain/ClassDomain/Project.swift @@ -18,6 +18,10 @@ let project = Project.makeModule( settings: .settings(), dependencies: [ .serviceAssembly, + .service(.apiEndpoint), + ], + testDependencies: [ + .service(.apiEndpoint), ], hasTests: true, hasInterface: true, diff --git a/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift b/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift index 98f712b2..15a3cad4 100644 --- a/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift +++ b/Projects/Domain/ClassDomain/Sources/ClassLiveDependencies.swift @@ -1,97 +1,90 @@ -// -// ClassLiveDependencies.swift -// ClassDomain -// -// Created by Picke on 10/11/26. -// - import ClassDomainInterface import ComposableArchitecture extension ClassUseCaseDependency: DependencyKey { - public static var liveValue: any ClassInterface { - ClassUseCaseImpl() - } + public static var liveValue: any ClassInterface { ClassUseCaseImpl() } } -extension ClassTicketUseCaseDependency: DependencyKey { - public static var liveValue: any ClassTicketInterface { - ClassTicketUseCaseImpl() - } +extension ClassRepositoryDependency: DependencyKey { + public static var liveValue: any ClassInterface { ClassRepositoryImpl() } } -extension ClassTicketRepositoryDependency: DependencyKey { - public static var liveValue: any ClassTicketInterface { - ClassTicketRepositoryImpl() - } +extension ClassRoomUseCaseDependency: DependencyKey { + public static var liveValue: any ClassRoomInterface { ClassUseCaseImpl() } +} + +extension ClassRoomRepositoryDependency: DependencyKey { + public static var liveValue: any ClassRoomInterface { ClassRepositoryImpl() } +} + +extension ClassRecommendationUseCaseDependency: DependencyKey { + public static var liveValue: any ClassRecommendationInterface { ClassUseCaseImpl() } +} + +extension ClassRecommendationRepositoryDependency: DependencyKey { + public static var liveValue: any ClassRecommendationInterface { ClassRepositoryImpl() } } extension ClassBattleUseCaseDependency: DependencyKey { - public static var liveValue: any ClassBattleInterface { - ClassBattleUseCaseImpl() - } + public static var liveValue: any ClassBattleInterface { ClassUseCaseImpl() } } extension ClassBattleRepositoryDependency: DependencyKey { - public static var liveValue: any ClassBattleInterface { - ClassBattleRepositoryImpl() - } + public static var liveValue: any ClassBattleInterface { ClassRepositoryImpl() } } extension ClassMemberUseCaseDependency: DependencyKey { - public static var liveValue: any ClassMemberInterface { - ClassMemberUseCaseImpl() - } + public static var liveValue: any ClassMemberInterface { ClassUseCaseImpl() } } extension ClassMemberRepositoryDependency: DependencyKey { - public static var liveValue: any ClassMemberInterface { - ClassMemberRepositoryImpl() - } + public static var liveValue: any ClassMemberInterface { ClassRepositoryImpl() } } -extension ClassAIChatUseCaseDependency: DependencyKey { - public static var liveValue: any ClassAIChatInterface { - ClassAIChatUseCaseImpl() - } +extension ClassDebateUseCaseDependency: DependencyKey { + public static var liveValue: any ClassDebateInterface { ClassUseCaseImpl() } } -extension ClassDebateUseCaseDependency: DependencyKey { - public static var liveValue: any ClassDebateInterface { - ClassDebateUseCaseImpl() - } +extension ClassDebateRepositoryDependency: DependencyKey { + public static var liveValue: any ClassDebateInterface { ClassRepositoryImpl() } } -extension ClassAIRepositoryDependency: DependencyKey { - public static var liveValue: any ClassAIRepositoryInterface { - ClassAIRepositoryImpl() - } +extension ClassTicketUseCaseDependency: DependencyKey { + public static var liveValue: any ClassTicketInterface { ClassUseCaseImpl() } } -extension ClassAIFeedbackUseCaseDependency: DependencyKey { - public static var liveValue: any ClassAIFeedbackInterface { - ClassAIFeedbackUseCaseImpl() - } +extension ClassTicketRepositoryDependency: DependencyKey { + public static var liveValue: any ClassTicketInterface { ClassRepositoryImpl() } } extension ClassResultUseCaseDependency: DependencyKey { - public static var liveValue: any ClassResultInterface { ClassResultUseCaseImpl() } + public static var liveValue: any ClassResultInterface { ClassUseCaseImpl() } } extension ClassResultRepositoryDependency: DependencyKey { - public static var liveValue: any ClassResultInterface { ClassResultRepositoryImpl() } + public static var liveValue: any ClassResultInterface { ClassRepositoryImpl() } } extension ClassTopicDraftUseCaseDependency: DependencyKey { - public static var liveValue: any ClassTopicDraftInterface { ClassTopicDraftUseCaseImpl() } + public static var liveValue: any ClassTopicDraftInterface { ClassUseCaseImpl() } } extension ClassTopicDraftRepositoryDependency: DependencyKey { - public static var liveValue: any ClassTopicDraftInterface { ClassTopicDraftRepositoryImpl() } + public static var liveValue: any ClassTopicDraftInterface { ClassRepositoryImpl() } } -extension ClassRepositoryDependency: DependencyKey { - public static var liveValue: any ClassInterface { - ClassLiveRepository(api: ClassRepositoryImpl()) - } +extension ClassReportUseCaseDependency: DependencyKey { + public static var liveValue: any ClassReportInterface { ClassUseCaseImpl() } +} + +extension ClassReportRepositoryDependency: DependencyKey { + public static var liveValue: any ClassReportInterface { ClassRepositoryImpl() } +} + +extension ClassLocalAIUseCaseDependency: DependencyKey { + public static var liveValue: any ClassLocalAIInterface { ClassUseCaseImpl() } +} + +extension ClassLocalAIRepositoryDependency: DependencyKey { + public static var liveValue: any ClassLocalAIInterface { ClassRepositoryImpl() } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift index 85268bff..08c157a4 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift @@ -6,9 +6,6 @@ struct ClassPersonalReportDTO: Decodable, Sendable { let title: String let stance: String? - func toDomain() -> ClassAIFeedbackVoteChoice { - ClassAIFeedbackVoteChoice(id: optionId, title: title, stance: stance) - } } struct Point: Decodable, Sendable { @@ -24,15 +21,6 @@ struct ClassPersonalReportDTO: Decodable, Sendable { let quote: String? let content: String - func toDomain() -> ClassAIFeedbackEvidence { - ClassAIFeedbackEvidence( - sourceType: sourceType, - sourceID: sourceId, - content: content, - quote: quote, - field: field - ) - } } let headline: String @@ -44,30 +32,6 @@ struct ClassPersonalReportDTO: Decodable, Sendable { let coverage: String? let excludedCount: Int? - func toDomain( - preOption: VoteOption?, - postOption: VoteOption?, - perspectiveCount: Int, - commentCount: Int - ) -> ClassAIPersonalFeedback { - let points = [strength, nextFocus].compactMap(\.self).map { - ClassAIFeedbackPoint(title: $0.title, detail: $0.body) - } - return ClassAIPersonalFeedback( - summaryTitle: headline, - summaryDetail: judgmentBasis, - points: points, - nextQuestion: nextQuestion, - nextHint: "", - evidence: evidence?.map { $0.toDomain() } ?? [], - coverage: coverage, - excludedCount: excludedCount ?? 0, - preOption: preOption?.toDomain(), - postOption: postOption?.toDomain(), - perspectiveCount: perspectiveCount, - commentCount: commentCount - ) - } } let state: String @@ -76,27 +40,6 @@ struct ClassPersonalReportDTO: Decodable, Sendable { let postOption: VoteOption? let perspectiveCount: Int? let commentCount: Int? - - func toDomain() throws -> ClassAIPersonalFeedback { - switch state { - case "SUCCEEDED", "LIMITED": - guard let feedback else { throw ClassAIFeedbackError.generationFailed } - return feedback.toDomain( - preOption: preOption, - postOption: postOption, - perspectiveCount: perspectiveCount ?? 0, - commentCount: commentCount ?? 0 - ) - case "BEFORE_CLOSE", "PENDING", "GENERATING": - throw ClassAIFeedbackError.modelNotReady - case "NOT_ELIGIBLE": - throw ClassAIFeedbackError.insufficientInput - case "FAILED": - throw ClassAIFeedbackError.generationFailed - default: - throw ClassAIFeedbackError.generationFailed - } - } } struct ClassInsightDTO: Decodable, Sendable { @@ -107,14 +50,6 @@ struct ClassInsightDTO: Decodable, Sendable { let content: String let quote: String? - func toDomain() -> ClassAIFeedbackEvidence { - ClassAIFeedbackEvidence( - sourceType: sourceType, - sourceID: id ?? sourceId, - content: content, - quote: quote - ) - } } let state: String @@ -124,26 +59,6 @@ struct ClassInsightDTO: Decodable, Sendable { let highlights: [Evidence]? let coverage: String? let excludedCount: Int? - - func toDomain() throws -> ClassAIInsight { - switch state { - case "SUCCEEDED", "LIMITED": - guard let insightText, !insightText.isEmpty else { throw ClassAIFeedbackError.generationFailed } - return ClassAIInsight( - text: insightText, - evidence: evidence?.map { $0.toDomain() } ?? [], - highlights: highlights?.map { $0.toDomain() } ?? [], - coverage: coverage, - excludedCount: excludedCount ?? 0 - ) - case "BEFORE_CLOSE", "PENDING", "GENERATING": - throw ClassAIFeedbackError.modelNotReady - case "FAILED": - throw retryable ? ClassAIFeedbackError.serverRetryAvailable : .generationFailed - default: - throw ClassAIFeedbackError.generationFailed - } - } } struct ClassInsightRetryDTO: Decodable, Sendable { diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleRecommendationDataDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleRecommendationDataDTO.swift index 503fad8f..ee5731b4 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleRecommendationDataDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleRecommendationDataDTO.swift @@ -14,42 +14,6 @@ struct ClassBattleRecommendationItemDTO: Decodable, Sendable { let difficulty: String let audioDuration: Int? let options: [ClassBattleRecommendationOptionDTO] - - func toDomain() throws -> ClassBattleSummary { - guard let category = ClassCategory.allCases.first(where: { $0.title == self.category }) else { - throw ClassError.unknown("알 수 없는 배틀 카테고리: \(category)") - } - let level: ClassAudienceLevel - switch difficulty { - case "BEGINNER": level = .middle - case "INTERMEDIATE": level = .high - case "ADVANCED": level = .adult - default: throw ClassError.unknown("알 수 없는 배틀 수준: \(difficulty)") - } - guard let optionA = options.first(where: { $0.position == "A" }), - let optionB = options.first(where: { $0.position == "B" }) - else { - throw ClassError.unknown("배틀의 A/B 선택지가 누락됐습니다.") - } - return ClassBattleSummary( - id: battleId, - title: title, - summary: summary ?? "", - thumbnailURL: thumbnailUrl.flatMap(URL.init(string:)), - philosopherA: optionA.representative ?? "", - optionATitle: optionA.title, - optionADescription: optionA.stance, - philosopherAImageURL: optionA.imageUrl.flatMap(URL.init(string:)), - philosopherB: optionB.representative ?? "", - optionBTitle: optionB.title, - optionBDescription: optionB.stance, - philosopherBImageURL: optionB.imageUrl.flatMap(URL.init(string:)), - category: category, - level: level, - audioDuration: audioDuration ?? 0, - viewCount: 0 - ) - } } struct ClassBattleRecommendationOptionDTO: Decodable, Sendable { diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift index dc845db8..c6ed6220 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift @@ -13,16 +13,6 @@ struct ClassBattleSessionDTO: Decodable, Sendable { let representative: String? let imageUrl: String? - func toDomain() -> ClassBattleOption { - ClassBattleOption( - id: optionId, - label: label, - title: title, - stance: stance ?? "", - representative: representative ?? "", - imageURL: imageUrl.flatMap(URL.init(string:)) - ) - } } let battleId: Int @@ -40,24 +30,6 @@ struct ClassBattleSessionDTO: Decodable, Sendable { let userVoteStatus: String? let currentStep: String? - func toDomain() throws -> ClassBattleDetail { - guard let currentStep = ClassBattleStep(rawValue: currentStep ?? "NONE") else { - throw ClassBattleError.invalidResponse - } - return ClassBattleDetail( - id: battleInfo.battleId, - title: battleInfo.title, - summary: battleInfo.summary ?? "", - thumbnailURL: battleInfo.thumbnailUrl.flatMap(URL.init(string:)), - description: description ?? "", - shareURL: shareUrl.flatMap(URL.init(string:)), - userVoteStatus: userVoteStatus, - currentStep: currentStep, - participantsCount: battleInfo.participantsCount ?? 0, - audioDuration: battleInfo.audioDuration ?? 0, - options: battleInfo.options.map { $0.toDomain() } - ) - } } let classId: Int @@ -68,26 +40,4 @@ struct ClassBattleSessionDTO: Decodable, Sendable { let canParticipate: Bool let participationCompleted: Bool let battle: Battle - - func toDomain() throws -> ClassBattleSession { - let role: ClassRole - switch myRole { - case "OWNER": role = .owner - case "MEMBER": role = .member - default: throw ClassBattleError.invalidResponse - } - guard let closeAt = ServerDateParser.parse(closeAt) else { - throw ClassBattleError.invalidResponse - } - return ClassBattleSession( - classID: classId, - myRole: role, - closeAt: closeAt, - closed: closed, - commentRequired: commentRequired, - canParticipate: canParticipate, - participationCompleted: participationCompleted, - battle: try battle.toDomain() - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassDebateDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassDebateDTO.swift index 3af7fee7..82f94ab8 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassDebateDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassDebateDTO.swift @@ -15,17 +15,6 @@ struct ClassDebateDTO: Decodable, Sendable { let speaker: String let content: String - func toDomain() throws -> ClassDebateMessage { - guard let speaker = ClassDebateSpeaker(rawValue: speaker) else { - throw ClassAIChatError.invalidOutput - } - return ClassDebateMessage( - id: messageId, - round: round, - speaker: speaker, - content: content - ) - } } let sessionId: Int @@ -40,24 +29,4 @@ struct ClassDebateDTO: Decodable, Sendable { let aiNotice: String let replyMinLength: Int let replyMaxLength: Int - - func toDomain() throws -> ClassDebate { - guard let status = ClassDebateStatus(rawValue: status) else { - throw ClassAIChatError.invalidOutput - } - return try ClassDebate( - sessionID: sessionId, - status: status, - round: round, - canReply: canReply, - canRetry: canRetry, - philosopherName: philosopher.name, - participantOptionTitle: participantOption.title, - opponentOptionTitle: opponentOption.title, - messages: messages.map { try $0.toDomain() }, - aiNotice: aiNotice, - replyMinLength: replyMinLength, - replyMaxLength: replyMaxLength - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMemberDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMemberDTO.swift index 4ac9bf56..ac7f0ba8 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMemberDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMemberDTO.swift @@ -7,23 +7,4 @@ struct ClassMemberDTO: Decodable, Sendable { let role: String let characterUrl: URL? let me: Bool - - func toDomain() -> ClassMember { - ClassMember( - id: memberId, - name: displayName, - isOwner: role == "OWNER", - characterURL: characterUrl - ) - } - - static func toMemberList(_ payload: [Self]) throws -> ClassMemberList { - guard let current = payload.first(where: \.me) else { - throw ClassError.unknown("내 멤버 정보를 찾을 수 없어요.") - } - return ClassMemberList( - members: payload.map { $0.toDomain() }, - currentMemberID: current.memberId - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift index 37b4ab9b..1bb7d9b9 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift @@ -6,13 +6,6 @@ struct ClassPerspectiveReceiptDTO: Decodable, Sendable { let perspectiveId: Int let status: String let createdAt: String - - func toDomain() throws -> ClassPerspectiveReceipt { - guard let createdAt = ServerDateParser.parse(createdAt) else { - throw ClassBattleError.invalidResponse - } - return ClassPerspectiveReceipt(id: perspectiveId, status: status, createdAt: createdAt) - } } struct ClassPerspectiveItemDTO: Decodable, Sendable { @@ -22,14 +15,6 @@ struct ClassPerspectiveItemDTO: Decodable, Sendable { let characterType: String? let characterImageUrl: String? - func toDomain() -> ClassPerspectiveAuthor { - ClassPerspectiveAuthor( - userTag: userTag, - nickname: nickname, - characterType: characterType, - characterImageURL: characterImageUrl.flatMap(URL.init(string:)) - ) - } } struct Option: Decodable, Sendable { @@ -38,9 +23,6 @@ struct ClassPerspectiveItemDTO: Decodable, Sendable { let title: String let stance: String? - func toDomain() -> ClassPerspectiveOption { - ClassPerspectiveOption(id: optionId, label: label, title: title, stance: stance) - } } let perspectiveId: Int @@ -53,36 +35,10 @@ struct ClassPerspectiveItemDTO: Decodable, Sendable { let isMyPerspective: Bool? let status: String? let createdAt: String - - func toDomain() throws -> ClassPerspectiveItem { - guard let createdAt = ServerDateParser.parse(createdAt) else { - throw ClassBattleError.invalidResponse - } - return ClassPerspectiveItem( - id: perspectiveId, - author: user.toDomain(), - option: option.toDomain(), - content: content, - likeCount: likeCount, - commentCount: commentCount, - isLiked: isLiked, - isMyPerspective: isMyPerspective, - status: status, - createdAt: createdAt - ) - } } struct ClassPerspectivePageDTO: Decodable, Sendable { let items: [ClassPerspectiveItemDTO] let nextCursor: String? let hasNext: Bool - - func toDomain() throws -> ClassPerspectivePage { - ClassPerspectivePage( - items: try items.map { try $0.toDomain() }, - nextCursor: nextCursor, - hasNext: hasNext - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift index 2a1e6df9..e311e01a 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift @@ -11,15 +11,6 @@ struct ClassResultDTO: Decodable, Sendable { let postVoteCount: Int let postVoteRatio: Double - func toDomain() -> ClassResultOption { - ClassResultOption( - id: optionId, - title: title, - preVoteCount: preVoteCount, - postVoteCount: postVoteCount, - postVoteRatio: postVoteRatio - ) - } } let activeMemberCount: Int @@ -30,17 +21,6 @@ struct ClassResultDTO: Decodable, Sendable { let postVoteCount: Int let options: [Option] - func toDomain() -> ClassResultStats { - ClassResultStats( - activeMemberCount: activeMemberCount, - completedCount: completedCount, - participationRate: participationRate, - commentWriterCount: commentWriterCount, - commentRate: commentRate, - postVoteCount: postVoteCount, - options: options.map { $0.toDomain() } - ) - } } let classId: Int @@ -49,18 +29,4 @@ struct ClassResultDTO: Decodable, Sendable { let stats: Stats let myReportState: String? let insightState: String? - - func toDomain() throws -> ClassResult { - guard let closeAt = ServerDateParser.parse(closeAt) else { - throw ClassError.network("클래스 마감일을 읽을 수 없습니다.") - } - return ClassResult( - classID: classId, - closed: closed, - closeAt: closeAt, - stats: stats.toDomain(), - myReportState: myReportState, - insightState: insightState - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTicketDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTicketDTO.swift index 1c177b87..b575b48f 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTicketDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTicketDTO.swift @@ -10,23 +10,4 @@ struct ClassTicketDTO: Decodable, Sendable { let expiresAt: String let remainingDays: Int let readGraceEndsAt: String - - func toDomain() throws -> ClassTicket { - guard - let status = ClassTicket.Status(rawValue: status), - let activatedAt = ServerDateParser.parse(activatedAt), - let expiresAt = ServerDateParser.parse(expiresAt), - let readGraceEndsAt = ServerDateParser.parse(readGraceEndsAt) - else { throw ClassTicketError.invalidResponse } - - return ClassTicket( - id: ticketId, - capacity: capacity, - status: status, - activatedAt: activatedAt, - expiresAt: expiresAt, - remainingDays: remainingDays, - readGraceEndsAt: readGraceEndsAt - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTicketPreviewDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTicketPreviewDTO.swift index 81214532..798ebb91 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTicketPreviewDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTicketPreviewDTO.swift @@ -7,17 +7,4 @@ struct ClassTicketPreviewDTO: Decodable, Sendable { let registerBy: String let usageMonths: Int let registeredByMe: Bool - - func toDomain() throws -> ClassTicketPreview { - guard let date = ServerDateParser.parse(registerBy) else { - throw ClassTicketError.invalidResponse - } - - return ClassTicketPreview( - capacity: capacity, - registerBy: date, - usageMonths: usageMonths, - registeredByMe: registeredByMe - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTopicDraftDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTopicDraftDTO.swift index 7162ec10..52d69840 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTopicDraftDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassTopicDraftDTO.swift @@ -14,19 +14,6 @@ struct ClassTopicCandidateDTO: Decodable, Sendable { let category: String let difficulty: String let options: [Option] - - func toDomain() -> ClassTopicCandidate { - ClassTopicCandidate( - id: candidateId, - question: question, - description: description, - category: category, - difficulty: difficulty, - options: options.map { - .init(label: $0.label, rationale: $0.rationale, philosopher: $0.philosopher) - } - ) - } } struct ClassTopicVersionDTO: Decodable, Sendable { @@ -48,29 +35,6 @@ struct ClassTopicVersionDTO: Decodable, Sendable { let category: String let difficulty: String let options: [Option] - - func toDomain() -> ClassTopicVersion { - ClassTopicVersion( - id: topicVersionId, - draftID: draftId, - versionNo: versionNo, - source: source, - question: question, - description: description, - category: category, - difficulty: difficulty, - options: options.map { - .init( - position: $0.position, - label: $0.label, - rationale: $0.rationale, - philosopher: $0.philosopher, - philosopherName: $0.philosopherName, - philosopherImageKey: $0.philosopherImageKey - ) - } - ) - } } struct ClassTopicDraftDTO: Decodable, Sendable { @@ -85,22 +49,6 @@ struct ClassTopicDraftDTO: Decodable, Sendable { let remainingValidations: Int let candidates: [ClassTopicCandidateDTO] let latestVersion: ClassTopicVersionDTO? - - func toDomain() -> ClassTopicDraft { - ClassTopicDraft( - id: draftId, - topicSeed: topicSeed, - difficulty: difficulty, - categories: categories, - suggestStatus: suggestStatus, - clarificationMessage: clarificationMessage, - batchNo: batchNo, - remainingSuggestions: remainingSuggestions, - remainingValidations: remainingValidations, - candidates: candidates.map { $0.toDomain() }, - latestVersion: latestVersion?.toDomain() - ) - } } struct ClassTopicValidationDTO: Decodable, Sendable { @@ -115,14 +63,4 @@ struct ClassTopicValidationDTO: Decodable, Sendable { let validatedTopicVersionId: Int? let remainingValidations: Int let version: ClassTopicVersionDTO? - - func toDomain() -> ClassTopicValidation { - ClassTopicValidation( - valid: valid, - issues: issues.map { .init(field: $0.field, code: $0.code, message: $0.message) }, - validatedTopicVersionID: validatedTopicVersionId, - remainingValidations: remainingValidations, - version: version?.toDomain() - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift index 399fb072..5f209386 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift @@ -5,13 +5,6 @@ import PickeCoreUtility struct ClassVoteReceiptDTO: Decodable, Sendable { let voteId: Int let status: String - - func toDomain() throws -> ClassVoteReceipt { - guard let status = ClassBattleStep(rawValue: status) else { - throw ClassBattleError.invalidResponse - } - return ClassVoteReceipt(voteID: voteId, status: status) - } } struct ClassVoteHistoryDTO: Decodable, Sendable { @@ -20,9 +13,6 @@ struct ClassVoteHistoryDTO: Decodable, Sendable { let label: String let title: String - func toDomain() -> ClassVoteChoice { - ClassVoteChoice(optionID: optionId, label: label, title: title) - } } let battleTitle: String @@ -30,19 +20,6 @@ struct ClassVoteHistoryDTO: Decodable, Sendable { let postVote: Choice? let status: String let opinionChanged: Bool? - - func toDomain() throws -> ClassVoteHistory { - guard let status = ClassBattleStep(rawValue: status) else { - throw ClassBattleError.invalidResponse - } - return ClassVoteHistory( - battleTitle: battleTitle, - preVote: preVote.toDomain(), - postVote: postVote?.toDomain(), - status: status, - opinionChanged: opinionChanged - ) - } } struct ClassVoteStatsDTO: Decodable, Sendable { @@ -53,29 +30,9 @@ struct ClassVoteStatsDTO: Decodable, Sendable { let voteCount: Int let ratio: Double - func toDomain() -> ClassVoteStatsOption { - ClassVoteStatsOption( - id: optionId, - title: title, - imageURL: imageUrl.flatMap(URL.init(string:)), - voteCount: voteCount, - ratio: ratio - ) - } } let options: [Option] let totalCount: Int let updatedAt: String - - func toDomain() throws -> ClassVoteStats { - guard let updatedAt = ServerDateParser.parse(updatedAt) else { - throw ClassBattleError.invalidResponse - } - return ClassVoteStats( - options: options.map { $0.toDomain() }, - totalCount: totalCount, - updatedAt: updatedAt - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassroomDataDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassroomDataDTO.swift index 54a0b963..01dd32fa 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassroomDataDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassroomDataDTO.swift @@ -14,25 +14,6 @@ struct ClassListItemDTO: Decodable, Sendable { let capacity: Int let closeAt: String let closed: Bool - - func toDomain() throws -> ClassRoom { - let closeDate = try closeAt.classDate() - return ClassRoom( - id: classId, - name: name, - joinCode: "", - battle: .classProjection(id: battleId, title: battleTitle, thumbnailUrl: battleThumbnailUrl), - deadline: .distantFuture, - closeAt: closeDate, - memberCount: memberCount, - role: try myRole.classRole(), - status: closed ? .closed : .open, - allowsAnonymousOpinion: false, - requiresComment: false, - memberCapacity: capacity, - battleType: battleType - ) - } } struct ClassDetailDataDTO: Decodable, Sendable { @@ -57,31 +38,6 @@ struct ClassDetailDataDTO: Decodable, Sendable { let closed: Bool let commentRequired: Bool let ownerParticipates: Bool - - func toDomain() throws -> ClassRoom { - return ClassRoom( - id: classId, - name: name, - joinCode: inviteCode ?? "", - battle: .classProjection( - id: battle.battleId, - title: battle.title, - summary: battle.summary ?? "", - thumbnailUrl: battle.thumbnailUrl - ), - deadline: try deadline.map { try $0.classDate() } ?? .distantFuture, - closeAt: try closeAt.classDate(), - memberCount: memberCount, - role: try myRole.classRole(), - status: closed ? .closed : .open, - allowsAnonymousOpinion: false, - requiresComment: commentRequired, - ownerParticipates: ownerParticipates, - memberCapacity: capacity, - battleType: battleType, - ticketExpiresAt: try ticketExpiresAt.classDate() - ) - } } struct ClassJoinPreviewDataDTO: Decodable, Sendable { @@ -93,59 +49,4 @@ struct ClassJoinPreviewDataDTO: Decodable, Sendable { let memberCount: Int let capacity: Int let closeAt: String - - func toDomain() throws -> ClassJoinPreview { - ClassJoinPreview( - inviteCode: inviteCode, - name: name, - battleTitle: battleTitle, - battleThumbnailURL: battleThumbnailUrl.flatMap(URL.init(string:)), - battleType: battleType, - memberCount: memberCount, - capacity: capacity, - closeAt: try closeAt.classDate() - ) - } -} - -private extension ClassBattleSummary { - static func classProjection( - id: Int, - title: String, - summary: String = "", - thumbnailUrl: String? - ) -> Self { - ClassBattleSummary( - id: id, - title: title, - summary: summary, - thumbnailURL: thumbnailUrl.flatMap(URL.init(string:)), - philosopherA: "", - optionATitle: "", - philosopherB: "", - optionBTitle: "", - category: .philosophy, - categoryDisplayTitle: "", - level: .middle, - audioDuration: 0, - viewCount: 0 - ) - } -} - -private extension String { - func classDate() throws -> Date { - guard let date = date(as: .serverDateTime) else { - throw ClassError.unknown("클래스 날짜 형식을 읽을 수 없습니다: \(self)") - } - return date - } - - func classRole() throws -> ClassRole { - switch self { - case "OWNER": return .owner - case "MEMBER": return .member - default: throw ClassError.unknown("알 수 없는 클래스 역할: \(self)") - } - } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift new file mode 100644 index 00000000..ffae479f --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift @@ -0,0 +1,102 @@ +import ClassDomainInterface + +extension ClassPersonalReportDTO.VoteOption { + func toDomain() -> ClassAIFeedbackVoteChoice { + ClassAIFeedbackVoteChoice(id: optionId, title: title, stance: stance) + } +} + +extension ClassPersonalReportDTO.Feedback.Evidence { + func toDomain() -> ClassAIFeedbackEvidence { + ClassAIFeedbackEvidence( + sourceType: sourceType, + sourceID: sourceId, + content: content, + quote: quote, + field: field + ) + } +} + +extension ClassPersonalReportDTO.Feedback { + func toDomain( + preOption: ClassPersonalReportDTO.VoteOption?, + postOption: ClassPersonalReportDTO.VoteOption?, + perspectiveCount: Int, + commentCount: Int + ) -> ClassAIPersonalFeedback { + let points = [strength, nextFocus].compactMap(\.self).map { + ClassAIFeedbackPoint(title: $0.title, detail: $0.body) + } + return ClassAIPersonalFeedback( + summaryTitle: headline, + summaryDetail: judgmentBasis, + points: points, + nextQuestion: nextQuestion, + nextHint: "", + evidence: evidence?.map { $0.toDomain() } ?? [], + coverage: coverage, + excludedCount: excludedCount ?? 0, + preOption: preOption?.toDomain(), + postOption: postOption?.toDomain(), + perspectiveCount: perspectiveCount, + commentCount: commentCount + ) + } +} + +extension ClassPersonalReportDTO { + func toDomain() throws -> ClassAIPersonalFeedback { + switch state { + case "SUCCEEDED", "LIMITED": + guard let feedback else { throw ClassAIFeedbackError.generationFailed } + return feedback.toDomain( + preOption: preOption, + postOption: postOption, + perspectiveCount: perspectiveCount ?? 0, + commentCount: commentCount ?? 0 + ) + case "BEFORE_CLOSE", "PENDING", "GENERATING": + throw ClassAIFeedbackError.modelNotReady + case "NOT_ELIGIBLE": + throw ClassAIFeedbackError.insufficientInput + case "FAILED": + throw ClassAIFeedbackError.generationFailed + default: + throw ClassAIFeedbackError.generationFailed + } + } +} + +extension ClassInsightDTO.Evidence { + func toDomain() -> ClassAIFeedbackEvidence { + ClassAIFeedbackEvidence( + sourceType: sourceType, + sourceID: id ?? sourceId, + content: content, + quote: quote + ) + } +} + +extension ClassInsightDTO { + func toDomain() throws -> ClassAIInsight { + switch state { + case "SUCCEEDED", "LIMITED": + guard let insightText, !insightText.isEmpty else { throw ClassAIFeedbackError.generationFailed } + return ClassAIInsight( + text: insightText, + evidence: evidence?.map { $0.toDomain() } ?? [], + highlights: highlights?.map { $0.toDomain() } ?? [], + coverage: coverage, + excludedCount: excludedCount ?? 0 + ) + case "BEFORE_CLOSE", "PENDING", "GENERATING": + throw ClassAIFeedbackError.modelNotReady + case "FAILED": + throw retryable ? ClassAIFeedbackError.serverRetryAvailable : .generationFailed + default: + throw ClassAIFeedbackError.generationFailed + } + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleRecommendationDataDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleRecommendationDataDTO+.swift new file mode 100644 index 00000000..2f3d57b1 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleRecommendationDataDTO+.swift @@ -0,0 +1,40 @@ +import ClassDomainInterface +import Foundation + +extension ClassBattleRecommendationItemDTO { + func toDomain() throws -> ClassBattleSummary { + guard let category = ClassCategory.allCases.first(where: { $0.title == self.category }) else { + throw ClassError.unknown("알 수 없는 배틀 카테고리: \(category)") + } + let level: ClassAudienceLevel + switch difficulty { + case "BEGINNER": level = .middle + case "INTERMEDIATE": level = .high + case "ADVANCED": level = .adult + default: throw ClassError.unknown("알 수 없는 배틀 수준: \(difficulty)") + } + guard let optionA = options.first(where: { $0.position == "A" }), + let optionB = options.first(where: { $0.position == "B" }) + else { + throw ClassError.unknown("배틀의 A/B 선택지가 누락됐습니다.") + } + return ClassBattleSummary( + id: battleId, + title: title, + summary: summary ?? "", + thumbnailURL: thumbnailUrl.flatMap(URL.init(string:)), + philosopherA: optionA.representative ?? "", + optionATitle: optionA.title, + optionADescription: optionA.stance, + philosopherAImageURL: optionA.imageUrl.flatMap(URL.init(string:)), + philosopherB: optionB.representative ?? "", + optionBTitle: optionB.title, + optionBDescription: optionB.stance, + philosopherBImageURL: optionB.imageUrl.flatMap(URL.init(string:)), + category: category, + level: level, + audioDuration: audioDuration ?? 0, + viewCount: 0 + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift new file mode 100644 index 00000000..da1592e7 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift @@ -0,0 +1,61 @@ +import ClassDomainInterface +import Foundation +import PickeCoreUtility + +extension ClassBattleSessionDTO.Battle.Info.Option { + func toDomain() -> ClassBattleOption { + ClassBattleOption( + id: optionId, + label: label, + title: title, + stance: stance ?? "", + representative: representative ?? "", + imageURL: imageUrl.flatMap(URL.init(string:)) + ) + } +} + +extension ClassBattleSessionDTO.Battle { + func toDomain() throws -> ClassBattleDetail { + guard let currentStep = ClassBattleStep(rawValue: currentStep ?? "NONE") else { + throw ClassBattleError.invalidResponse + } + return ClassBattleDetail( + id: battleInfo.battleId, + title: battleInfo.title, + summary: battleInfo.summary ?? "", + thumbnailURL: battleInfo.thumbnailUrl.flatMap(URL.init(string:)), + description: description ?? "", + shareURL: shareUrl.flatMap(URL.init(string:)), + userVoteStatus: userVoteStatus, + currentStep: currentStep, + participantsCount: battleInfo.participantsCount ?? 0, + audioDuration: battleInfo.audioDuration ?? 0, + options: battleInfo.options.map { $0.toDomain() } + ) + } +} + +extension ClassBattleSessionDTO { + func toDomain() throws -> ClassBattleSession { + let role: ClassRole + switch myRole { + case "OWNER": role = .owner + case "MEMBER": role = .member + default: throw ClassBattleError.invalidResponse + } + guard let closeAt = ServerDateParser.parse(closeAt) else { + throw ClassBattleError.invalidResponse + } + return ClassBattleSession( + classID: classId, + myRole: role, + closeAt: closeAt, + closed: closed, + commentRequired: commentRequired, + canParticipate: canParticipate, + participationCompleted: participationCompleted, + battle: try battle.toDomain() + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassDebateDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassDebateDTO+.swift new file mode 100644 index 00000000..7b8bbb91 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassDebateDTO+.swift @@ -0,0 +1,37 @@ +import ClassDomainInterface + +extension ClassDebateDTO.Message { + func toDomain() throws -> ClassDebateMessage { + guard let speaker = ClassDebateSpeaker(rawValue: speaker) else { + throw ClassAIChatError.invalidOutput + } + return ClassDebateMessage( + id: messageId, + round: round, + speaker: speaker, + content: content + ) + } +} + +extension ClassDebateDTO { + func toDomain() throws -> ClassDebate { + guard let status = ClassDebateStatus(rawValue: status) else { + throw ClassAIChatError.invalidOutput + } + return try ClassDebate( + sessionID: sessionId, + status: status, + round: round, + canReply: canReply, + canRetry: canRetry, + philosopherName: philosopher.name, + participantOptionTitle: participantOption.title, + opponentOptionTitle: opponentOption.title, + messages: messages.map { try $0.toDomain() }, + aiNotice: aiNotice, + replyMinLength: replyMinLength, + replyMaxLength: replyMaxLength + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift new file mode 100644 index 00000000..bf06f21d --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift @@ -0,0 +1,25 @@ +import ClassDomainInterface +import Foundation + +extension ClassMemberDTO { + func toDomain() -> ClassMember { + ClassMember( + id: memberId, + name: displayName, + isOwner: role == "OWNER", + characterURL: characterUrl + ) + } +} + +extension ClassMemberDTO { + static func toMemberList(_ payload: [Self]) throws -> ClassMemberList { + guard let current = payload.first(where: \.me) else { + throw ClassError.unknown("내 멤버 정보를 찾을 수 없어요.") + } + return ClassMemberList( + members: payload.map { $0.toDomain() }, + currentMemberID: current.memberId + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift new file mode 100644 index 00000000..e99be720 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift @@ -0,0 +1,59 @@ +import ClassDomainInterface +import Foundation +import PickeCoreUtility + +extension ClassPerspectiveReceiptDTO { + func toDomain() throws -> ClassPerspectiveReceipt { + guard let createdAt = ServerDateParser.parse(createdAt) else { + throw ClassBattleError.invalidResponse + } + return ClassPerspectiveReceipt(id: perspectiveId, status: status, createdAt: createdAt) + } +} + +extension ClassPerspectiveItemDTO.User { + func toDomain() -> ClassPerspectiveAuthor { + ClassPerspectiveAuthor( + userTag: userTag, + nickname: nickname, + characterType: characterType, + characterImageURL: characterImageUrl.flatMap(URL.init(string:)) + ) + } +} + +extension ClassPerspectiveItemDTO.Option { + func toDomain() -> ClassPerspectiveOption { + ClassPerspectiveOption(id: optionId, label: label, title: title, stance: stance) + } +} + +extension ClassPerspectiveItemDTO { + func toDomain() throws -> ClassPerspectiveItem { + guard let createdAt = ServerDateParser.parse(createdAt) else { + throw ClassBattleError.invalidResponse + } + return ClassPerspectiveItem( + id: perspectiveId, + author: user.toDomain(), + option: option.toDomain(), + content: content, + likeCount: likeCount, + commentCount: commentCount, + isLiked: isLiked, + isMyPerspective: isMyPerspective, + status: status, + createdAt: createdAt + ) + } +} + +extension ClassPerspectivePageDTO { + func toDomain() throws -> ClassPerspectivePage { + ClassPerspectivePage( + items: try items.map { try $0.toDomain() }, + nextCursor: nextCursor, + hasNext: hasNext + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift new file mode 100644 index 00000000..6ed67760 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift @@ -0,0 +1,45 @@ +import ClassDomainInterface +import Foundation +import PickeCoreUtility + +extension ClassResultDTO.Stats.Option { + func toDomain() -> ClassResultOption { + ClassResultOption( + id: optionId, + title: title, + preVoteCount: preVoteCount, + postVoteCount: postVoteCount, + postVoteRatio: postVoteRatio + ) + } +} + +extension ClassResultDTO.Stats { + func toDomain() -> ClassResultStats { + ClassResultStats( + activeMemberCount: activeMemberCount, + completedCount: completedCount, + participationRate: participationRate, + commentWriterCount: commentWriterCount, + commentRate: commentRate, + postVoteCount: postVoteCount, + options: options.map { $0.toDomain() } + ) + } +} + +extension ClassResultDTO { + func toDomain() throws -> ClassResult { + guard let closeAt = ServerDateParser.parse(closeAt) else { + throw ClassError.network("클래스 마감일을 읽을 수 없습니다.") + } + return ClassResult( + classID: classId, + closed: closed, + closeAt: closeAt, + stats: stats.toDomain(), + myReportState: myReportState, + insightState: insightState + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift new file mode 100644 index 00000000..bec26639 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift @@ -0,0 +1,24 @@ +import ClassDomainInterface +import Foundation +import PickeCoreUtility + +extension ClassTicketDTO { + func toDomain() throws -> ClassTicket { + guard + let status = ClassTicket.Status(rawValue: status), + let activatedAt = ServerDateParser.parse(activatedAt), + let expiresAt = ServerDateParser.parse(expiresAt), + let readGraceEndsAt = ServerDateParser.parse(readGraceEndsAt) + else { throw ClassTicketError.invalidResponse } + + return ClassTicket( + id: ticketId, + capacity: capacity, + status: status, + activatedAt: activatedAt, + expiresAt: expiresAt, + remainingDays: remainingDays, + readGraceEndsAt: readGraceEndsAt + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketPreviewDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketPreviewDTO+.swift new file mode 100644 index 00000000..b02f5ba6 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketPreviewDTO+.swift @@ -0,0 +1,18 @@ +import ClassDomainInterface +import Foundation +import PickeCoreUtility + +extension ClassTicketPreviewDTO { + func toDomain() throws -> ClassTicketPreview { + guard let date = ServerDateParser.parse(registerBy) else { + throw ClassTicketError.invalidResponse + } + + return ClassTicketPreview( + capacity: capacity, + registerBy: date, + usageMonths: usageMonths, + registeredByMe: registeredByMe + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTopicDraftDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTopicDraftDTO+.swift new file mode 100644 index 00000000..3c796894 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTopicDraftDTO+.swift @@ -0,0 +1,72 @@ +import ClassDomainInterface +import Foundation + +extension ClassTopicCandidateDTO { + func toDomain() -> ClassTopicCandidate { + ClassTopicCandidate( + id: candidateId, + question: question, + description: description, + category: category, + difficulty: difficulty, + options: options.map { + .init(label: $0.label, rationale: $0.rationale, philosopher: $0.philosopher) + } + ) + } +} + +extension ClassTopicVersionDTO { + func toDomain() -> ClassTopicVersion { + ClassTopicVersion( + id: topicVersionId, + draftID: draftId, + versionNo: versionNo, + source: source, + question: question, + description: description, + category: category, + difficulty: difficulty, + options: options.map { + .init( + position: $0.position, + label: $0.label, + rationale: $0.rationale, + philosopher: $0.philosopher, + philosopherName: $0.philosopherName, + philosopherImageKey: $0.philosopherImageKey + ) + } + ) + } +} + +extension ClassTopicDraftDTO { + func toDomain() -> ClassTopicDraft { + ClassTopicDraft( + id: draftId, + topicSeed: topicSeed, + difficulty: difficulty, + categories: categories, + suggestStatus: suggestStatus, + clarificationMessage: clarificationMessage, + batchNo: batchNo, + remainingSuggestions: remainingSuggestions, + remainingValidations: remainingValidations, + candidates: candidates.map { $0.toDomain() }, + latestVersion: latestVersion?.toDomain() + ) + } +} + +extension ClassTopicValidationDTO { + func toDomain() -> ClassTopicValidation { + ClassTopicValidation( + valid: valid, + issues: issues.map { .init(field: $0.field, code: $0.code, message: $0.message) }, + validatedTopicVersionID: validatedTopicVersionId, + remainingValidations: remainingValidations, + version: version?.toDomain() + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift new file mode 100644 index 00000000..14d33d7a --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift @@ -0,0 +1,58 @@ +import ClassDomainInterface +import Foundation +import PickeCoreUtility + +extension ClassVoteReceiptDTO { + func toDomain() throws -> ClassVoteReceipt { + guard let status = ClassBattleStep(rawValue: status) else { + throw ClassBattleError.invalidResponse + } + return ClassVoteReceipt(voteID: voteId, status: status) + } +} + +extension ClassVoteHistoryDTO.Choice { + func toDomain() -> ClassVoteChoice { + ClassVoteChoice(optionID: optionId, label: label, title: title) + } +} + +extension ClassVoteHistoryDTO { + func toDomain() throws -> ClassVoteHistory { + guard let status = ClassBattleStep(rawValue: status) else { + throw ClassBattleError.invalidResponse + } + return ClassVoteHistory( + battleTitle: battleTitle, + preVote: preVote.toDomain(), + postVote: postVote?.toDomain(), + status: status, + opinionChanged: opinionChanged + ) + } +} + +extension ClassVoteStatsDTO.Option { + func toDomain() -> ClassVoteStatsOption { + ClassVoteStatsOption( + id: optionId, + title: title, + imageURL: imageUrl.flatMap(URL.init(string:)), + voteCount: voteCount, + ratio: ratio + ) + } +} + +extension ClassVoteStatsDTO { + func toDomain() throws -> ClassVoteStats { + guard let updatedAt = ServerDateParser.parse(updatedAt) else { + throw ClassBattleError.invalidResponse + } + return ClassVoteStats( + options: options.map { $0.toDomain() }, + totalCount: totalCount, + updatedAt: updatedAt + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassroomDataDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassroomDataDTO+.swift new file mode 100644 index 00000000..290a6675 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassroomDataDTO+.swift @@ -0,0 +1,108 @@ +import ClassDomainInterface +import Foundation +import PickeCoreUtility + +extension ClassListItemDTO { + func toDomain() throws -> ClassRoom { + let closeDate = try closeAt.classDate() + return ClassRoom( + id: classId, + name: name, + joinCode: "", + battle: .classProjection(id: battleId, title: battleTitle, thumbnailUrl: battleThumbnailUrl), + deadline: .distantFuture, + closeAt: closeDate, + memberCount: memberCount, + role: try myRole.classRole(), + status: closed ? .closed : .open, + allowsAnonymousOpinion: false, + requiresComment: false, + memberCapacity: capacity, + battleType: battleType + ) + } +} + +extension ClassDetailDataDTO { + func toDomain() throws -> ClassRoom { + return ClassRoom( + id: classId, + name: name, + joinCode: inviteCode ?? "", + battle: .classProjection( + id: battle.battleId, + title: battle.title, + summary: battle.summary ?? "", + thumbnailUrl: battle.thumbnailUrl + ), + deadline: try deadline.map { try $0.classDate() } ?? .distantFuture, + closeAt: try closeAt.classDate(), + memberCount: memberCount, + role: try myRole.classRole(), + status: closed ? .closed : .open, + allowsAnonymousOpinion: false, + requiresComment: commentRequired, + ownerParticipates: ownerParticipates, + memberCapacity: capacity, + battleType: battleType, + ticketExpiresAt: try ticketExpiresAt.classDate() + ) + } +} + +extension ClassJoinPreviewDataDTO { + func toDomain() throws -> ClassJoinPreview { + ClassJoinPreview( + inviteCode: inviteCode, + name: name, + battleTitle: battleTitle, + battleThumbnailURL: battleThumbnailUrl.flatMap(URL.init(string:)), + battleType: battleType, + memberCount: memberCount, + capacity: capacity, + closeAt: try closeAt.classDate() + ) + } +} + +private extension ClassBattleSummary { + static func classProjection( + id: Int, + title: String, + summary: String = "", + thumbnailUrl: String? + ) -> Self { + ClassBattleSummary( + id: id, + title: title, + summary: summary, + thumbnailURL: thumbnailUrl.flatMap(URL.init(string:)), + philosopherA: "", + optionATitle: "", + philosopherB: "", + optionBTitle: "", + category: .philosophy, + categoryDisplayTitle: "", + level: .middle, + audioDuration: 0, + viewCount: 0 + ) + } +} + +private extension String { + func classDate() throws -> Date { + guard let date = date(as: .serverDateTime) else { + throw ClassError.unknown("클래스 날짜 형식을 읽을 수 없습니다: \(self)") + } + return date + } + + func classRole() throws -> ClassRole { + switch self { + case "OWNER": return .owner + case "MEMBER": return .member + default: throw ClassError.unknown("알 수 없는 클래스 역할: \(self)") + } + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassLiveRepository.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassLiveRepository.swift deleted file mode 100644 index f2397dd1..00000000 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassLiveRepository.swift +++ /dev/null @@ -1,55 +0,0 @@ -import ClassDomainInterface -import Foundation - -/// 운영 경로에서는 서버에 연결되지 않은 기능을 성공으로 가장하지 않는다. -struct ClassLiveRepository: ClassInterface { - let api: any ClassInterface - - func fetchMyClasses() async throws -> [ClassRoom] { - try await api.fetchMyClasses() - } - - func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] { - try await api.fetchRecommendedBattles(filter: filter) - } - - func createClass(_ creation: ClassCreation) async throws -> ClassRoom { - try await api.createClass(creation) - } - - func fetchClass(id: Int) async throws -> ClassRoom { - try await api.fetchClass(id: id) - } - - func fetchClass(joinCode: String) async throws -> ClassRoom { - try await api.fetchClass(joinCode: joinCode) - } - - func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview { - try await api.fetchClassPreview(joinCode: joinCode) - } - - func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom { - try await api.joinClass(joinCode: joinCode, nickname: nickname) - } - - func updateDeadline(id: Int, deadline: Date) async throws -> ClassRoom { - try await api.updateDeadline(id: id, deadline: deadline) - } - - func deleteClass(id: Int) async throws { - try await api.deleteClass(id: id) - } - - func leaveClass(id: Int) async throws { - try await api.leaveClass(id: id) - } - - func fetchReport(roomID: Int) async throws -> ClassReport? { - try await api.fetchReport(roomID: roomID) - } - - func fetchOwnerDashboard(roomID: Int) async throws -> ClassOwnerDashboardData? { - try await api.fetchOwnerDashboard(roomID: roomID) - } -} diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassBattleRepositoryImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift similarity index 85% rename from Projects/Domain/ClassDomain/Sources/Repository/ClassBattleRepositoryImpl.swift rename to Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift index 15855b62..cbd1ae18 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassBattleRepositoryImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift @@ -1,18 +1,21 @@ +// +// ClassRepositoryImpl+Battle.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import APIEndpoint import ClassDomainInterface -import Dependencies import Foundation import PickeNetwork -public struct ClassBattleRepositoryImpl: ClassBattleInterface { - @Dependency(\.networkClient) private var client - - public init() {} - +extension ClassRepositoryImpl { public func fetchBattle(classID: Int) async throws -> ClassBattleSession { do { let payload = try await client.send(ClassBattleService.detail(classID: classID), as: ClassBattleSessionDTO.self) return try payload.toDomain() - } catch { throw Self.map(error) } + } catch { throw Self.battleError(error) } } public func submitPreVote( @@ -25,7 +28,7 @@ public struct ClassBattleRepositoryImpl: ClassBattleInterface { as: ClassVoteReceiptDTO.self ) return try payload.toDomain() - } catch { throw Self.map(error) } + } catch { throw Self.battleError(error) } } public func completeTTS(classID: Int) async throws { @@ -34,7 +37,7 @@ public struct ClassBattleRepositoryImpl: ClassBattleInterface { ClassBattleService.ttsComplete(classID: classID), as: PickeEmptyResponse.self ) - } catch { throw Self.map(error) } + } catch { throw Self.battleError(error) } } public func submitPostVote( @@ -47,21 +50,21 @@ public struct ClassBattleRepositoryImpl: ClassBattleInterface { as: ClassVoteReceiptDTO.self ) return try payload.toDomain() - } catch { throw Self.map(error) } + } catch { throw Self.battleError(error) } } public func fetchMyVotes(classID: Int) async throws -> ClassVoteHistory { do { let payload = try await client.send(ClassBattleService.myVotes(classID: classID), as: ClassVoteHistoryDTO.self) return try payload.toDomain() - } catch { throw Self.map(error) } + } catch { throw Self.battleError(error) } } public func fetchVoteStats(classID: Int) async throws -> ClassVoteStats { do { let payload = try await client.send(ClassBattleService.voteStats(classID: classID), as: ClassVoteStatsDTO.self) return try payload.toDomain() - } catch { throw Self.map(error) } + } catch { throw Self.battleError(error) } } public func publishPerspective( @@ -74,7 +77,7 @@ public struct ClassBattleRepositoryImpl: ClassBattleInterface { as: ClassPerspectiveReceiptDTO.self ) return try payload.toDomain() - } catch { throw Self.map(error) } + } catch { throw Self.battleError(error) } } public func fetchPerspectives( @@ -96,7 +99,7 @@ public struct ClassBattleRepositoryImpl: ClassBattleInterface { as: ClassPerspectivePageDTO.self ) return try payload.toDomain() - } catch { throw Self.map(error) } + } catch { throw Self.battleError(error) } } public func fetchMyPerspectives(classID: Int) async throws -> [ClassPerspectiveItem] { @@ -106,10 +109,10 @@ public struct ClassBattleRepositoryImpl: ClassBattleInterface { as: [ClassPerspectiveItemDTO].self ) return try payload.map { try $0.toDomain() } - } catch { throw Self.map(error) } + } catch { throw Self.battleError(error) } } - private static func map(_ error: Error) -> ClassBattleError { + private static func battleError(_ error: Error) -> ClassBattleError { if let error = error as? ClassBattleError { return error } if let error = error as? PickeNetworkError, case let .response(response) = error diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassAIRepositoryImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Debate.swift similarity index 90% rename from Projects/Domain/ClassDomain/Sources/Repository/ClassAIRepositoryImpl.swift rename to Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Debate.swift index a88a6a46..0e0ad010 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassAIRepositoryImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Debate.swift @@ -1,13 +1,16 @@ +// +// ClassRepositoryImpl+Debate.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import APIEndpoint import ClassDomainInterface -import Dependencies import Foundation import PickeNetwork -public struct ClassAIRepositoryImpl: ClassAIRepositoryInterface { - @Dependency(\.networkClient) private var client - - public init() {} - +extension ClassRepositoryImpl { public func fetchDebate(classID: Int) async throws -> ClassDebate { try await debate(.fetch(classID)) } diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassAIChatUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+LocalAI.swift similarity index 53% rename from Projects/Domain/ClassDomain/Sources/UseCase/ClassAIChatUseCaseImpl.swift rename to Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+LocalAI.swift index 774531a5..3d67e335 100644 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassAIChatUseCaseImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+LocalAI.swift @@ -1,17 +1,15 @@ // -// ClassAIChatUseCaseImpl.swift +// ClassRepositoryImpl+LocalAI.swift // ClassDomain // -// Created by Picke on 10/11/26. +// Created by Picke on 10/26/26. // import ClassDomainInterface import Foundation -public struct ClassAIChatUseCaseImpl: ClassAIChatInterface { - public init() {} - - public func start(_ id: UUID, _ context: ClassAIChatContext) async throws { +extension ClassRepositoryImpl { + public func startAIChat(_ id: UUID, _ context: ClassAIChatContext) async throws { #if canImport(FoundationModels) if #available(iOS 26.0, *) { try await ClassAIChatFoundationEngine.shared.start(id: id, context: context) @@ -21,11 +19,7 @@ public struct ClassAIChatUseCaseImpl: ClassAIChatInterface { throw ClassAIChatError.unavailable } - public func respond( - _ id: UUID, - _ text: String, - isFinalRound: Bool - ) async throws -> String { + public func respondAIChat(_ id: UUID, _ text: String, isFinalRound: Bool) async throws -> String { #if canImport(FoundationModels) if #available(iOS 26.0, *) { return try await ClassAIChatFoundationEngine.shared.respond( @@ -38,11 +32,20 @@ public struct ClassAIChatUseCaseImpl: ClassAIChatInterface { throw ClassAIChatError.unavailable } - public func end(_ id: UUID) async { + public func endAIChat(_ id: UUID) async { #if canImport(FoundationModels) if #available(iOS 26.0, *) { await ClassAIChatFoundationEngine.shared.end(id: id) } #endif } + + public func generateAIFeedback(context: ClassAIFeedbackContext) async throws -> ClassAIFeedbackResult { + #if canImport(FoundationModels) + if #available(iOS 26.0, *) { + return try await ClassAIFeedbackFoundationEngine.generate(context: context) + } + #endif + throw ClassAIFeedbackError.deviceNotEligible + } } diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassMemberRepositoryImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Member.swift similarity index 79% rename from Projects/Domain/ClassDomain/Sources/Repository/ClassMemberRepositoryImpl.swift rename to Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Member.swift index 1c100886..98eda7e0 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassMemberRepositoryImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Member.swift @@ -1,13 +1,16 @@ +// +// ClassRepositoryImpl+Member.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import APIEndpoint import ClassDomainInterface -import Dependencies import Foundation import PickeNetwork -public struct ClassMemberRepositoryImpl: ClassMemberInterface { - @Dependency(\.networkClient) private var client - - public init() {} - +extension ClassRepositoryImpl { public func fetchMembers(classID: Int) async throws -> ClassMemberList { do { let data = try await client.send( @@ -16,7 +19,7 @@ public struct ClassMemberRepositoryImpl: ClassMemberInterface { ) return try ClassMemberDTO.toMemberList(data) } catch { - throw Self.classError(error) + throw Self.memberError(error) } } @@ -28,7 +31,7 @@ public struct ClassMemberRepositoryImpl: ClassMemberInterface { ) return data.toDomain() } catch { - throw Self.classError(error) + throw Self.memberError(error) } } @@ -39,11 +42,11 @@ public struct ClassMemberRepositoryImpl: ClassMemberInterface { as: PickeEmptyResponse.self ) } catch { - throw Self.classError(error) + throw Self.memberError(error) } } - private static func classError(_ error: Error) -> ClassError { + private static func memberError(_ error: Error) -> ClassError { guard let networkError = error as? PickeNetworkError else { return ClassError.from(error) } guard case let .response(response) = networkError else { return ClassError.from(networkError) } return ClassErrorMapper.map( diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift new file mode 100644 index 00000000..040b152f --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift @@ -0,0 +1,32 @@ +// +// ClassRepositoryImpl+Recommendation.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import APIEndpoint +import ClassDomainInterface +import Foundation +import PickeNetwork + +extension ClassRepositoryImpl { + public func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] { + guard (1 ... 3).contains(filter.categories.count), filter.keyword.count <= 50 else { + throw ClassError.unknown("추천 조건을 확인해 주세요.") + } + do { + let data = try await client.send( + ClassBattleRecommendationService.recommendations(filter: filter), + as: ClassBattleRecommendationDataDTO.self + ) + return try data.items.map { try $0.toDomain() } + } catch let error as PickeNetworkError { + if case let .response(response) = error { + throw ClassError.network(response.message ?? "배틀 추천을 불러오지 못했습니다.") + } + throw ClassError.from(error) + } + } + +} diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Report.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Report.swift new file mode 100644 index 00000000..2e9da22e --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Report.swift @@ -0,0 +1,22 @@ +// +// ClassRepositoryImpl+Report.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import ClassDomainInterface +import Foundation + +extension ClassRepositoryImpl { + private static let unavailableMessage = "클래스 서버 API가 연결되지 않았습니다." + + public func fetchReport(roomID _: Int) async throws -> ClassReport? { + throw ClassError.network(Self.unavailableMessage) + } + + public func fetchOwnerDashboard(roomID _: Int) async throws -> ClassOwnerDashboardData? { + throw ClassError.network(Self.unavailableMessage) + } + +} diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassResultRepositoryImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift similarity index 88% rename from Projects/Domain/ClassDomain/Sources/Repository/ClassResultRepositoryImpl.swift rename to Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift index 49aeb2b4..6f7c8789 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassResultRepositoryImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift @@ -1,13 +1,17 @@ +// +// ClassRepositoryImpl+Result.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import APIEndpoint import ClassDomainInterface -import Dependencies +import Foundation import PickeNetwork -public struct ClassResultRepositoryImpl: ClassResultInterface { - @Dependency(\.networkClient) private var client - - public init() {} - - public func fetch(roomID: Int) async throws -> ClassResult { +extension ClassRepositoryImpl { + public func fetchResult(roomID: Int) async throws -> ClassResult { do { let payload = try await client.send(ClassResultService.result(roomID), as: ClassResultDTO.self) return try payload.toDomain() @@ -21,7 +25,7 @@ public struct ClassResultRepositoryImpl: ClassResultInterface { } } - public func fetchPersonal(roomID: Int) async throws -> ClassAIPersonalFeedback { + public func fetchPersonalResult(roomID: Int) async throws -> ClassAIPersonalFeedback { do { let payload = try await client.send(ClassResultService.myReport(roomID), as: ClassPersonalReportDTO.self) return try payload.toDomain() diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Room.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Room.swift new file mode 100644 index 00000000..51d3848d --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Room.swift @@ -0,0 +1,119 @@ +// +// ClassRepositoryImpl+Room.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import APIEndpoint +import ClassDomainInterface +import Foundation +import PickeNetwork + +extension ClassRepositoryImpl { + public func fetchMyClasses() async throws -> [ClassRoom] { + do { + let data = try await client.send(ClassroomService.myClasses, as: [ClassListItemDTO].self) + return try data.map { try $0.toDomain() } + } catch { + throw Self.classError(error) + } + } + + public func createClass(_ creation: ClassCreation) async throws -> ClassRoom { + do { + let data = try await client.send(ClassroomService.create(creation), as: ClassDetailDataDTO.self) + return try data.toDomain() + } catch { + throw Self.classError(error) + } + } + + public func fetchClass(id: Int) async throws -> ClassRoom { + do { + let data = try await client.send(ClassroomService.detail(id), as: ClassDetailDataDTO.self) + return try data.toDomain() + } catch { + throw Self.classError(error) + } + } + + public func fetchClass(joinCode _: String) async throws -> ClassRoom { + throw ClassError.unknown("참여 미리보기는 클래스 상세를 제공하지 않습니다.") + } + + public func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview { + do { + let data = try await client.send( + ClassroomService.joinPreview(joinCode), + as: ClassJoinPreviewDataDTO.self + ) + return try data.toDomain() + } catch { + throw Self.classError(error) + } + } + + public func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom { + do { + let data = try await client.send( + ClassroomService.join(code: joinCode, nickname: nickname), + as: ClassDetailDataDTO.self + ) + return try data.toDomain() + } catch { + throw Self.classError(error) + } + } + + public func updateDeadline(id: Int, deadline: Date) async throws -> ClassRoom { + do { + _ = try await client.send( + ClassroomService.updateDeadline(id: id, deadline: deadline), + as: PickeEmptyResponse.self + ) + let data = try await client.send(ClassroomService.detail(id), as: ClassDetailDataDTO.self) + return try data.toDomain() + } catch { + throw Self.classError(error) + } + } + + public func updateName(id: Int, name: String) async throws -> ClassRoom { + do { + _ = try await client.send( + ClassroomService.updateName(id: id, name: name), + as: PickeEmptyResponse.self + ) + let data = try await client.send(ClassroomService.detail(id), as: ClassDetailDataDTO.self) + return try data.toDomain() + } catch { + throw Self.classError(error) + } + } + + public func deleteClass(id: Int) async throws { + do { + _ = try await client.send(ClassroomService.delete(id), as: PickeEmptyResponse.self) + } catch { + throw Self.classError(error) + } + } + + public func leaveClass(id: Int) async throws { + do { + _ = try await client.send(ClassroomService.leave(id), as: PickeEmptyResponse.self) + } catch { + throw Self.classError(error) + } + } + + private static func classError(_ error: Error) -> ClassError { + guard let networkError = error as? PickeNetworkError else { return ClassError.from(error) } + guard case let .response(response) = networkError else { return ClassError.from(networkError) } + return ClassErrorMapper.map( + code: response.code, + message: response.message, + fallback: "클래스 요청을 처리하지 못했습니다." + ) + }} diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassTicketRepositoryImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Ticket.swift similarity index 74% rename from Projects/Domain/ClassDomain/Sources/Repository/ClassTicketRepositoryImpl.swift rename to Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Ticket.swift index eea34db9..60820b94 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassTicketRepositoryImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Ticket.swift @@ -1,28 +1,31 @@ +// +// ClassRepositoryImpl+Ticket.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import APIEndpoint import ClassDomainInterface -import Dependencies import Foundation import PickeNetwork -public struct ClassTicketRepositoryImpl: ClassTicketInterface { - @Dependency(\.networkClient) private var client - - public init() {} - - public func preview(code: String) async throws -> ClassTicketPreview { +extension ClassRepositoryImpl { + public func previewTicket(code: String) async throws -> ClassTicketPreview { do { let payload = try await client.send(ClassTicketService.preview(code: code), as: ClassTicketPreviewDTO.self) return try payload.toDomain() } catch { - throw Self.map(error) + throw Self.ticketError(error) } } - public func register(code: String) async throws -> ClassTicket { + public func registerTicket(code: String) async throws -> ClassTicket { do { let payload = try await client.send(ClassTicketService.register(code: code), as: ClassTicketDTO.self) return try payload.toDomain() } catch { - throw Self.map(error) + throw Self.ticketError(error) } } @@ -31,11 +34,11 @@ public struct ClassTicketRepositoryImpl: ClassTicketInterface { let payload = try await client.send(ClassTicketService.me, as: ClassTicketDTO.self) return try payload.toDomain() } catch { - throw Self.map(error) + throw Self.ticketError(error) } } - static func map(_ error: Error) -> ClassTicketError { + static func ticketError(_ error: Error) -> ClassTicketError { if let error = error as? ClassTicketError { return error } if let error = error as? PickeNetworkError { if case let .response(response) = error { diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+TopicDraft.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+TopicDraft.swift new file mode 100644 index 00000000..bc17a59c --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+TopicDraft.swift @@ -0,0 +1,55 @@ +// +// ClassRepositoryImpl+TopicDraft.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import APIEndpoint +import ClassDomainInterface +import Foundation +import PickeNetwork + +extension ClassRepositoryImpl { + public func createTopicDraft(filter: ClassTopicFilter) async throws -> ClassTopicDraft { + try await sendTopicDraft(ClassTopicDraftService.create(filter), as: ClassTopicDraftDTO.self).toDomain() + } + + public func fetchTopicDraft(id: Int) async throws -> ClassTopicDraft { + try await sendTopicDraft(ClassTopicDraftService.detail(id), as: ClassTopicDraftDTO.self).toDomain() + } + + public func suggestTopicDraft(id: Int, filter: ClassTopicFilter?) async throws -> ClassTopicDraft { + try await sendTopicDraft(ClassTopicDraftService.suggest(id, filter), as: ClassTopicDraftDTO.self).toDomain() + } + + public func selectTopicDraft(draftID: Int, batchNo: Int, candidateID: Int) async throws -> ClassTopicVersion { + try await sendTopicDraft( + ClassTopicDraftService.select(draftID: draftID, batchNo: batchNo, candidateID: candidateID), + as: ClassTopicVersionDTO.self + ).toDomain() + } + + public func validateTopicDraft(draftID: Int, baseVersionID: Int, candidate: ClassTopicCandidate) async throws -> ClassTopicValidation { + try await sendTopicDraft( + ClassTopicDraftService.validate(draftID: draftID, baseVersionID: baseVersionID, candidate: candidate), + as: ClassTopicValidationDTO.self + ).toDomain() + } + + private func sendTopicDraft( + _ request: some PickeDataRequest, + as type: T.Type + ) async throws -> T { + do { + return try await client.send(request, as: type) + } catch let error as PickeNetworkError { + if case let .response(response) = error { + throw ClassError.network(response.message ?? "AI 주제 요청을 처리하지 못했어요.") + } + throw ClassError.from(error) + } catch { + throw ClassError.from(error) + } + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift index 971011f7..e8470a03 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl.swift @@ -5,134 +5,15 @@ // Created by Picke on 10/11/26. // +import APIEndpoint import ClassDomainInterface import Dependencies import Foundation import PickeNetwork -public struct ClassRepositoryImpl: ClassInterface { - private static let unavailableMessage = "클래스 서버 API가 연결되지 않았습니다." - @Dependency(\.networkClient) private var client +public final class ClassRepositoryImpl: ClassInterface { + @Dependency(\.networkClient) var client public init() {} - public func fetchMyClasses() async throws -> [ClassRoom] { - do { - let data = try await client.send(ClassroomService.myClasses, as: [ClassListItemDTO].self) - return try data.map { try $0.toDomain() } - } catch { - throw Self.classError(error) - } - } - - public func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] { - guard (1 ... 3).contains(filter.categories.count), filter.keyword.count <= 50 else { - throw ClassError.unknown("추천 조건을 확인해 주세요.") - } - do { - let data = try await client.send( - ClassBattleRecommendationService.recommendations(filter: filter), - as: ClassBattleRecommendationDataDTO.self - ) - return try data.items.map { try $0.toDomain() } - } catch let error as PickeNetworkError { - if case let .response(response) = error { - throw ClassError.network(response.message ?? "배틀 추천을 불러오지 못했습니다.") - } - throw ClassError.from(error) - } - } - - public func createClass(_ creation: ClassCreation) async throws -> ClassRoom { - do { - let data = try await client.send(ClassroomService.create(creation), as: ClassDetailDataDTO.self) - return try data.toDomain() - } catch { - throw Self.classError(error) - } - } - - public func fetchClass(id: Int) async throws -> ClassRoom { - do { - let data = try await client.send(ClassroomService.detail(id), as: ClassDetailDataDTO.self) - return try data.toDomain() - } catch { - throw Self.classError(error) - } - } - - public func fetchClass(joinCode _: String) async throws -> ClassRoom { - throw ClassError.unknown("참여 미리보기는 클래스 상세를 제공하지 않습니다.") - } - - public func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview { - do { - let data = try await client.send( - ClassroomService.joinPreview(joinCode), - as: ClassJoinPreviewDataDTO.self - ) - return try data.toDomain() - } catch { - throw Self.classError(error) - } - } - - public func joinClass(joinCode: String, nickname: String) async throws -> ClassRoom { - do { - let data = try await client.send( - ClassroomService.join(code: joinCode, nickname: nickname), - as: ClassDetailDataDTO.self - ) - return try data.toDomain() - } catch { - throw Self.classError(error) - } - } - - public func updateDeadline(id: Int, deadline: Date) async throws -> ClassRoom { - do { - _ = try await client.send( - ClassroomService.updateDeadline(id: id, deadline: deadline), - as: PickeEmptyResponse.self - ) - let data = try await client.send(ClassroomService.detail(id), as: ClassDetailDataDTO.self) - return try data.toDomain() - } catch { - throw Self.classError(error) - } - } - - public func deleteClass(id: Int) async throws { - do { - _ = try await client.send(ClassroomService.delete(id), as: PickeEmptyResponse.self) - } catch { - throw Self.classError(error) - } - } - - public func leaveClass(id: Int) async throws { - do { - _ = try await client.send(ClassroomService.leave(id), as: PickeEmptyResponse.self) - } catch { - throw Self.classError(error) - } - } - - public func fetchReport(roomID _: Int) async throws -> ClassReport? { - throw ClassError.network(Self.unavailableMessage) - } - - public func fetchOwnerDashboard(roomID _: Int) async throws -> ClassOwnerDashboardData? { - throw ClassError.network(Self.unavailableMessage) - } - - private static func classError(_ error: Error) -> ClassError { - guard let networkError = error as? PickeNetworkError else { return ClassError.from(error) } - guard case let .response(response) = networkError else { return ClassError.from(networkError) } - return ClassErrorMapper.map( - code: response.code, - message: response.message, - fallback: "클래스 요청을 처리하지 못했습니다." - ) - } } diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassTopicDraftRepositoryImpl.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassTopicDraftRepositoryImpl.swift deleted file mode 100644 index f2a28513..00000000 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassTopicDraftRepositoryImpl.swift +++ /dev/null @@ -1,52 +0,0 @@ -import ClassDomainInterface -import Dependencies -import Foundation -import PickeNetwork - -public struct ClassTopicDraftRepositoryImpl: ClassTopicDraftInterface { - @Dependency(\.networkClient) private var client - - public init() {} - - public func create(filter: ClassTopicFilter) async throws -> ClassTopicDraft { - try await send(ClassTopicDraftService.create(filter), as: ClassTopicDraftDTO.self).toDomain() - } - - public func fetch(id: Int) async throws -> ClassTopicDraft { - try await send(ClassTopicDraftService.detail(id), as: ClassTopicDraftDTO.self).toDomain() - } - - public func suggest(id: Int, filter: ClassTopicFilter?) async throws -> ClassTopicDraft { - try await send(ClassTopicDraftService.suggest(id, filter), as: ClassTopicDraftDTO.self).toDomain() - } - - public func select(draftID: Int, batchNo: Int, candidateID: Int) async throws -> ClassTopicVersion { - try await send( - ClassTopicDraftService.select(draftID: draftID, batchNo: batchNo, candidateID: candidateID), - as: ClassTopicVersionDTO.self - ).toDomain() - } - - public func validate(draftID: Int, baseVersionID: Int, candidate: ClassTopicCandidate) async throws -> ClassTopicValidation { - try await send( - ClassTopicDraftService.validate(draftID: draftID, baseVersionID: baseVersionID, candidate: candidate), - as: ClassTopicValidationDTO.self - ).toDomain() - } - - private func send( - _ request: some PickeDataRequest, - as type: T.Type - ) async throws -> T { - do { - return try await client.send(request, as: type) - } catch let error as PickeNetworkError { - if case let .response(response) = error { - throw ClassError.network(response.message ?? "AI 주제 요청을 처리하지 못했어요.") - } - throw ClassError.from(error) - } catch { - throw ClassError.from(error) - } - } -} diff --git a/Projects/Domain/ClassDomain/Sources/Service/ClassBattleService.swift b/Projects/Domain/ClassDomain/Sources/Service/ClassBattleService.swift deleted file mode 100644 index ff57bab0..00000000 --- a/Projects/Domain/ClassDomain/Sources/Service/ClassBattleService.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Alamofire -import API -import Foundation -import PickeNetwork - -enum ClassBattleService { - case detail(classID: Int) - case preVote(classID: Int, optionID: Int) - case ttsComplete(classID: Int) - case postVote(classID: Int, optionID: Int) - case myVotes(classID: Int) - case voteStats(classID: Int) - case publishPerspective(classID: Int, content: String) - case perspectives(classID: Int, cursor: String?, size: Int?, optionID: Int?, sort: String?) - case myPerspectives(classID: Int) -} - -extension ClassBattleService: PickeDataRequest { - var domain: any PickeDomainType { ClassBattleDomain() } - - var path: String { - switch self { - case let .detail(classID): return "classes/\(classID)/battle" - case let .preVote(classID, _): return "classes/\(classID)/battle/votes/pre" - case let .ttsComplete(classID): return "classes/\(classID)/battle/votes/tts-complete" - case let .postVote(classID, _): return "classes/\(classID)/battle/votes/post" - case let .myVotes(classID): return "classes/\(classID)/battle/votes/me" - case let .voteStats(classID): return "classes/\(classID)/battle/vote-stats" - case let .publishPerspective(classID, _): return "classes/\(classID)/battle/perspectives" - case let .perspectives(classID, _, _, _, _): return "classes/\(classID)/battle/perspectives" - case let .myPerspectives(classID): return "classes/\(classID)/battle/perspectives/me" - } - } - - var method: HTTPMethod { - switch self { - case .detail, .myVotes, .voteStats, .perspectives, .myPerspectives: return .get - case .preVote, .ttsComplete, .postVote, .publishPerspective: return .post - } - } - - var parameters: (any Encodable & Sendable)? { - switch self { - case let .preVote(_, optionID), let .postVote(_, optionID): - return VoteBody(optionId: optionID) - case let .publishPerspective(_, content): - return PerspectiveBody(content: content) - case let .perspectives(_, cursor, size, optionID, sort): - return PerspectivesQuery(cursor: cursor, size: size, optionId: optionID, sort: sort) - case .detail, .ttsComplete, .myVotes, .voteStats, .myPerspectives: - return nil - } - } -} - -private struct ClassBattleDomain: PickeDomainType { - var baseURLString: String { PieckeDomain.battle.baseURLString } - var url: String { "api/v1/" } -} - -private struct VoteBody: Encodable, Sendable { - let optionId: Int -} - -private struct PerspectiveBody: Encodable, Sendable { - let content: String -} - -private struct PerspectivesQuery: Encodable, Sendable { - let cursor: String? - let size: Int? - let optionId: Int? - let sort: String? -} diff --git a/Projects/Domain/ClassDomain/Sources/Service/ClassDebateService.swift b/Projects/Domain/ClassDomain/Sources/Service/ClassDebateService.swift deleted file mode 100644 index 9500fd78..00000000 --- a/Projects/Domain/ClassDomain/Sources/Service/ClassDebateService.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Alamofire -import API -import Foundation -import PickeNetwork - -enum ClassDebateService { - case fetch(Int) - case start(Int) - case submitReply(classID: Int, round: Int, content: String, requestKey: UUID) - case retry(Int) -} - -extension ClassDebateService: PickeDataRequest { - var domain: any PickeDomainType { - ClassDebateDomain() - } - - var path: String { - switch self { - case let .fetch(id), let .start(id): return "classes/\(id)/battle/debate" - case let .submitReply(classID, _, _, _): return "classes/\(classID)/battle/debate/replies" - case let .retry(id): return "classes/\(id)/battle/debate/retry" - } - } - - var method: HTTPMethod { - switch self { - case .fetch: return .get - case .start, .submitReply, .retry: return .post - } - } - - var parameters: (any Encodable & Sendable)? { - switch self { - case let .submitReply(_, round, content, requestKey): - return ReplyBody(roundIndex: round, content: content, requestKey: requestKey.uuidString) - case .fetch, .start, .retry: - return nil - } - } -} - -private struct ClassDebateDomain: PickeDomainType { - var baseURLString: String { - PieckeDomain.battle.baseURLString - } - - var url: String { - "api/v1/" - } -} - -private struct ReplyBody: Encodable, Sendable { - let roundIndex: Int - let content: String - let requestKey: String -} diff --git a/Projects/Domain/ClassDomain/Sources/Service/ClassMemberService.swift b/Projects/Domain/ClassDomain/Sources/Service/ClassMemberService.swift deleted file mode 100644 index 4bc34deb..00000000 --- a/Projects/Domain/ClassDomain/Sources/Service/ClassMemberService.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Alamofire -import API -import Foundation -import PickeNetwork - -enum ClassMemberService { - case members(classID: Int) - case updateMyName(classID: Int, name: String) - case remove(classID: Int, memberID: Int) -} - -extension ClassMemberService: PickeDataRequest { - var domain: any PickeDomainType { - ClassMemberDomain() - } - - var path: String { - switch self { - case let .members(classID): return "classes/\(classID)/members" - case let .updateMyName(classID, _): return "classes/\(classID)/members/me" - case let .remove(classID, memberID): return "classes/\(classID)/members/\(memberID)" - } - } - - var method: HTTPMethod { - switch self { - case .members: return .get - case .updateMyName: return .patch - case .remove: return .delete - } - } - - var parameters: (any Encodable & Sendable)? { - switch self { - case .members, .remove: return nil - case let .updateMyName(_, name): return ClassMemberNameBody(displayName: name) - } - } -} - -private struct ClassMemberDomain: PickeDomainType { - var baseURLString: String { - PieckeDomain.battle.baseURLString - } - - var url: String { - "api/v1/" - } -} - -private struct ClassMemberNameBody: Encodable, Sendable { - let displayName: String -} diff --git a/Projects/Domain/ClassDomain/Sources/Service/ClassResultService.swift b/Projects/Domain/ClassDomain/Sources/Service/ClassResultService.swift deleted file mode 100644 index a17cb75c..00000000 --- a/Projects/Domain/ClassDomain/Sources/Service/ClassResultService.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Alamofire -import API -import PickeNetwork - -enum ClassResultService { - case result(Int) - case myReport(Int) - case insight(Int) - case retryInsight(Int) -} - -extension ClassResultService: PickeDataRequest { - var domain: any PickeDomainType { - ClassResultDomain() - } - - var path: String { - switch self { - case let .result(id): return "classes/\(id)/result" - case let .myReport(id): return "classes/\(id)/result/my-report" - case let .insight(id): return "classes/\(id)/result/insight" - case let .retryInsight(id): return "classes/\(id)/result/insight/retry" - } - } - - var method: HTTPMethod { - switch self { - case .result, .myReport, .insight: return .get - case .retryInsight: return .post - } - } - - var parameters: (any Encodable & Sendable)? { - nil - } -} - -private struct ClassResultDomain: PickeDomainType { - var baseURLString: String { - PieckeDomain.battle.baseURLString - } - - var url: String { - "api/v1/" - } -} diff --git a/Projects/Domain/ClassDomain/Sources/Service/ClassTicketService.swift b/Projects/Domain/ClassDomain/Sources/Service/ClassTicketService.swift deleted file mode 100644 index 6c91df78..00000000 --- a/Projects/Domain/ClassDomain/Sources/Service/ClassTicketService.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Alamofire -import API -import Foundation -import PickeNetwork - -enum ClassTicketService { - case preview(code: String) - case register(code: String) - case me -} - -extension ClassTicketService: PickeDataRequest { - var domain: any PickeDomainType { ClassTicketDomain() } - - var path: String { - switch self { - case .preview: return "class-tickets/preview" - case .register: return "class-tickets/register" - case .me: return "class-tickets/me" - } - } - - var method: HTTPMethod { - switch self { - case .preview, .me: return .get - case .register: return .post - } - } - - var parameters: (any Encodable & Sendable)? { - switch self { - case let .preview(code): return ClassTicketCodeRequest(code: code) - case let .register(code): return ClassTicketCodeRequest(code: code) - case .me: return nil - } - } -} - -private struct ClassTicketDomain: PickeDomainType { - var baseURLString: String { PieckeDomain.battle.baseURLString } - var url: String { "api/v1/" } -} - -private struct ClassTicketCodeRequest: Encodable, Sendable { - let code: String -} diff --git a/Projects/Domain/ClassDomain/Sources/Service/ClassTopicDraftService.swift b/Projects/Domain/ClassDomain/Sources/Service/ClassTopicDraftService.swift deleted file mode 100644 index 61f83be5..00000000 --- a/Projects/Domain/ClassDomain/Sources/Service/ClassTopicDraftService.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Alamofire -import API -import ClassDomainInterface -import Foundation -import PickeNetwork - -enum ClassTopicDraftService { - case create(ClassTopicFilter) - case detail(Int) - case suggest(Int, ClassTopicFilter?) - case select(draftID: Int, batchNo: Int, candidateID: Int) - case validate(draftID: Int, baseVersionID: Int, candidate: ClassTopicCandidate) -} - -extension ClassTopicDraftService: PickeDataRequest { - var domain: any PickeDomainType { ClassTopicDraftDomain() } - - var path: String { - switch self { - case .create: return "class-topic-drafts" - case let .detail(id): return "class-topic-drafts/\(id)" - case let .suggest(id, _): return "class-topic-drafts/\(id)/suggestions" - case let .select(draftID, _, _): return "class-topic-drafts/\(draftID)/selection" - case let .validate(draftID, _, _): return "class-topic-drafts/\(draftID)/validations" - } - } - - var method: HTTPMethod { - switch self { - case .detail: return .get - case .create, .suggest, .select, .validate: return .post - } - } - - var parameters: (any Encodable & Sendable)? { - switch self { - case let .create(filter): - return TopicFilterBody(filter: filter) - case .detail: - return nil - case let .suggest(_, filter): - return filter.map(TopicFilterBody.init) - case let .select(_, batchNo, candidateID): - return TopicSelectionBody(batchNo: batchNo, candidateId: candidateID) - case let .validate(_, baseVersionID, candidate): - return TopicValidationBody(baseVersionId: baseVersionID, candidate: candidate) - } - } -} - -private struct ClassTopicDraftDomain: PickeDomainType { - var baseURLString: String { PieckeDomain.battle.baseURLString } - var url: String { "api/v1/" } -} - -private struct TopicFilterBody: Encodable, Sendable { - let topicSeed: String - let difficulty: String - let categories: [String] - - init(filter: ClassTopicFilter) { - topicSeed = filter.keyword.trimmingCharacters(in: .whitespacesAndNewlines) - difficulty = filter.level.topicAPIValue - categories = filter.categories.reduce(into: [String]()) { titles, category in - if !titles.contains(category.title) { titles.append(category.title) } - } - } -} - -private struct TopicSelectionBody: Encodable, Sendable { - let batchNo: Int - let candidateId: Int -} - -private struct TopicValidationBody: Encodable, Sendable { - struct Option: Encodable, Sendable { - let label: String - let rationale: String - let philosopher: String? - } - - let baseVersionId: Int - let question: String - let description: String - let options: [Option] - - init( - baseVersionId: Int, - candidate: ClassTopicCandidate - ) { - self.baseVersionId = baseVersionId - question = candidate.question - description = candidate.description - options = candidate.options.map { - Option(label: $0.label, rationale: $0.rationale, philosopher: $0.philosopher) - } - } -} - -private extension ClassAudienceLevel { - var topicAPIValue: String { - switch self { - case .middle: return "BEGINNER" - case .high: return "INTERMEDIATE" - case .adult: return "ADVANCED" - } - } -} diff --git a/Projects/Domain/ClassDomain/Sources/Service/ClassroomService.swift b/Projects/Domain/ClassDomain/Sources/Service/ClassroomService.swift deleted file mode 100644 index d436aa02..00000000 --- a/Projects/Domain/ClassDomain/Sources/Service/ClassroomService.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Alamofire -import API -import ClassDomainInterface -import Foundation -import PickeCoreUtility -import PickeNetwork - -enum ClassroomService { - case myClasses - case create(ClassCreation) - case detail(Int) - case updateDeadline(id: Int, deadline: Date) - case delete(Int) - case joinPreview(String) - case join(code: String, nickname: String) - case leave(Int) -} - -extension ClassroomService: PickeDataRequest { - var domain: any PickeDomainType { ClassroomDomain() } - - var path: String { - switch self { - case .myClasses, .create: - return "classes" - case let .detail(id), let .delete(id), let .updateDeadline(id, _): - return "classes/\(id)" - case .joinPreview: - return "classes/join/preview" - case .join: - return "classes/join" - case let .leave(id): - return "classes/\(id)/members/me" - } - } - - var method: HTTPMethod { - switch self { - case .myClasses, .detail, .joinPreview: return .get - case .create, .join: return .post - case .updateDeadline: return .patch - case .delete, .leave: return .delete - } - } - - var parameters: (any Encodable & Sendable)? { - switch self { - case let .create(creation): - return CreateClassBody(creation: creation) - case let .updateDeadline(_, deadline): - return UpdateClassBody(deadline: deadline.formatted(.serverDateTime)) - case let .joinPreview(code): - return ClassJoinPreviewQuery(inviteCode: code) - case let .join(code, nickname): - return JoinClassBody(inviteCode: code, displayName: nickname) - case .myClasses, .detail, .delete, .leave: - return nil - } - } -} - -private struct ClassroomDomain: PickeDomainType { - var baseURLString: String { PieckeDomain.battle.baseURLString } - var url: String { "api/v1/" } -} - -private struct CreateClassBody: Encodable, Sendable { - let name: String - let battleId: Int? - let topicVersionId: Int? - let deadline: String? - let commentRequired: Bool - let ownerParticipates: Bool - - init(creation: ClassCreation) { - name = creation.name - battleId = creation.battleId - topicVersionId = creation.topicVersionId - deadline = creation.deadline == .distantFuture - ? nil - : creation.deadline.formatted(.serverDateTime) - commentRequired = creation.requiresComment - ownerParticipates = creation.ownerParticipates - } -} - -private struct UpdateClassBody: Encodable, Sendable { - let deadline: String -} - -private struct ClassJoinPreviewQuery: Encodable, Sendable { - let inviteCode: String -} - -private struct JoinClassBody: Encodable, Sendable { - let inviteCode: String - let displayName: String -} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassAIFeedbackUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassAIFeedbackUseCaseImpl.swift deleted file mode 100644 index 0d640b12..00000000 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassAIFeedbackUseCaseImpl.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ClassAIFeedbackUseCaseImpl.swift -// ClassDomain -// -// Created by Picke on 10/12/26. -// - -import ClassDomainInterface - -public struct ClassAIFeedbackUseCaseImpl: ClassAIFeedbackInterface { - public init() {} - - public func generate(context: ClassAIFeedbackContext) async throws -> ClassAIFeedbackResult { - #if canImport(FoundationModels) - if #available(iOS 26.0, *) { - return try await ClassAIFeedbackFoundationEngine.generate(context: context) - } - #endif - throw ClassAIFeedbackError.deviceNotEligible - } -} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassResultUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassResultUseCaseImpl.swift deleted file mode 100644 index 4afb50c9..00000000 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassResultUseCaseImpl.swift +++ /dev/null @@ -1,24 +0,0 @@ -import ClassDomainInterface -import Dependencies - -public struct ClassResultUseCaseImpl: ClassResultInterface { - @Dependency(\.classResultRepository) private var repository - - public init() {} - - public func fetch(roomID: Int) async throws -> ClassResult { - try await repository.fetch(roomID: roomID) - } - - public func fetchPersonal(roomID: Int) async throws -> ClassAIPersonalFeedback { - try await repository.fetchPersonal(roomID: roomID) - } - - public func fetchInsight(roomID: Int) async throws -> ClassAIInsight { - try await repository.fetchInsight(roomID: roomID) - } - - public func retryInsight(roomID: Int) async throws { - try await repository.retryInsight(roomID: roomID) - } -} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassTicketUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassTicketUseCaseImpl.swift deleted file mode 100644 index 286276a9..00000000 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassTicketUseCaseImpl.swift +++ /dev/null @@ -1,21 +0,0 @@ -import ClassDomainInterface -import ComposableArchitecture -import Foundation - -public struct ClassTicketUseCaseImpl: ClassTicketInterface { - @Dependency(\.classTicketRepository) private var repository - - public init() {} - - public func preview(code: String) async throws -> ClassTicketPreview { - try await repository.preview(code: code) - } - - public func register(code: String) async throws -> ClassTicket { - try await repository.register(code: code) - } - - public func fetchMyTicket() async throws -> ClassTicket { - try await repository.fetchMyTicket() - } -} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassTopicDraftUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassTopicDraftUseCaseImpl.swift deleted file mode 100644 index 6d38842b..00000000 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassTopicDraftUseCaseImpl.swift +++ /dev/null @@ -1,40 +0,0 @@ -import ClassDomainInterface -import ComposableArchitecture -import Foundation - -public struct ClassTopicDraftUseCaseImpl: ClassTopicDraftInterface { - @Dependency(\.classTopicDraftRepository) private var repository - - public init() {} - - public func create(filter: ClassTopicFilter) async throws -> ClassTopicDraft { - try await repository.create(filter: filter) - } - - public func fetch(id: Int) async throws -> ClassTopicDraft { - try await repository.fetch(id: id) - } - - public func suggest( - id: Int, - filter: ClassTopicFilter? - ) async throws -> ClassTopicDraft { - try await repository.suggest(id: id, filter: filter) - } - - public func select( - draftID: Int, - batchNo: Int, - candidateID: Int - ) async throws -> ClassTopicVersion { - try await repository.select(draftID: draftID, batchNo: batchNo, candidateID: candidateID) - } - - public func validate( - draftID: Int, - baseVersionID: Int, - candidate: ClassTopicCandidate - ) async throws -> ClassTopicValidation { - try await repository.validate(draftID: draftID, baseVersionID: baseVersionID, candidate: candidate) - } -} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassBattleUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift similarity index 72% rename from Projects/Domain/ClassDomain/Sources/UseCase/ClassBattleUseCaseImpl.swift rename to Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift index 8f68e36f..9434363c 100644 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassBattleUseCaseImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift @@ -1,20 +1,19 @@ +// +// ClassUseCaseImpl+Battle.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + import ClassDomainInterface -import ComposableArchitecture import Foundation -public struct ClassBattleUseCaseImpl: ClassBattleInterface { - @Dependency(\.classBattleRepository) private var repository - - public init() {} - +extension ClassUseCaseImpl { public func fetchBattle(classID: Int) async throws -> ClassBattleSession { try await repository.fetchBattle(classID: classID) } - public func submitPreVote( - classID: Int, - optionID: Int - ) async throws -> ClassVoteReceipt { + public func submitPreVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt { try await repository.submitPreVote(classID: classID, optionID: optionID) } @@ -22,10 +21,7 @@ public struct ClassBattleUseCaseImpl: ClassBattleInterface { try await repository.completeTTS(classID: classID) } - public func submitPostVote( - classID: Int, - optionID: Int - ) async throws -> ClassVoteReceipt { + public func submitPostVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt { try await repository.submitPostVote(classID: classID, optionID: optionID) } @@ -37,10 +33,7 @@ public struct ClassBattleUseCaseImpl: ClassBattleInterface { try await repository.fetchVoteStats(classID: classID) } - public func publishPerspective( - classID: Int, - content: String - ) async throws -> ClassPerspectiveReceipt { + public func publishPerspective(classID: Int, content: String) async throws -> ClassPerspectiveReceipt { try await repository.publishPerspective(classID: classID, content: content) } diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassDebateUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Debate.swift similarity index 56% rename from Projects/Domain/ClassDomain/Sources/UseCase/ClassDebateUseCaseImpl.swift rename to Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Debate.swift index 7976c43d..7ebd1a8c 100644 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassDebateUseCaseImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Debate.swift @@ -1,21 +1,23 @@ +// +// ClassUseCaseImpl+Debate.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + import ClassDomainInterface -import Dependencies import Foundation -public struct ClassDebateUseCaseImpl: ClassDebateInterface { - @Dependency(\.classAIRepository) private var repository - - public init() {} - - public func fetch(classID: Int) async throws -> ClassDebate { +extension ClassUseCaseImpl { + public func fetchDebate(classID: Int) async throws -> ClassDebate { try await repository.fetchDebate(classID: classID) } - public func start(classID: Int) async throws -> ClassDebate { + public func startDebate(classID: Int) async throws -> ClassDebate { try await repository.startDebate(classID: classID) } - public func submitReply( + public func submitDebateReply( classID: Int, round: Int, content: String, @@ -29,7 +31,7 @@ public struct ClassDebateUseCaseImpl: ClassDebateInterface { ) } - public func retry(classID: Int) async throws -> ClassDebate { + public func retryDebate(classID: Int) async throws -> ClassDebate { try await repository.retryDebate(classID: classID) } } diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+LocalAI.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+LocalAI.swift new file mode 100644 index 00000000..a4296451 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+LocalAI.swift @@ -0,0 +1,27 @@ +// +// ClassUseCaseImpl+LocalAI.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import ClassDomainInterface +import Foundation + +extension ClassUseCaseImpl { + public func startAIChat(_ id: UUID, _ context: ClassAIChatContext) async throws { + try await repository.startAIChat(id, context) + } + + public func respondAIChat(_ id: UUID, _ text: String, isFinalRound: Bool) async throws -> String { + try await repository.respondAIChat(id, text, isFinalRound: isFinalRound) + } + + public func endAIChat(_ id: UUID) async { + await repository.endAIChat(id) + } + + public func generateAIFeedback(context: ClassAIFeedbackContext) async throws -> ClassAIFeedbackResult { + try await repository.generateAIFeedback(context: context) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassMemberUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Member.swift similarity index 74% rename from Projects/Domain/ClassDomain/Sources/UseCase/ClassMemberUseCaseImpl.swift rename to Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Member.swift index 969f1ea6..8018d456 100644 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassMemberUseCaseImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Member.swift @@ -1,12 +1,14 @@ +// +// ClassUseCaseImpl+Member.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + import ClassDomainInterface -import ComposableArchitecture import Foundation -public struct ClassMemberUseCaseImpl: ClassMemberInterface { - @Dependency(\.classMemberRepository) private var repository - - public init() {} - +extension ClassUseCaseImpl { public func fetchMembers(classID: Int) async throws -> ClassMemberList { try await repository.fetchMembers(classID: classID) } diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Recommendation.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Recommendation.swift new file mode 100644 index 00000000..68b196d1 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Recommendation.swift @@ -0,0 +1,15 @@ +// +// ClassUseCaseImpl+Recommendation.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import ClassDomainInterface +import Foundation + +extension ClassUseCaseImpl { + public func fetchRecommendedBattles(filter: ClassTopicFilter) async throws -> [ClassBattleSummary] { + try await repository.fetchRecommendedBattles(filter: filter) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Report.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Report.swift new file mode 100644 index 00000000..39328de6 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Report.swift @@ -0,0 +1,19 @@ +// +// ClassUseCaseImpl+Report.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import ClassDomainInterface +import Foundation + +extension ClassUseCaseImpl { + public func fetchReport(roomID: Int) async throws -> ClassReport? { + try await repository.fetchReport(roomID: roomID) + } + + public func fetchOwnerDashboard(roomID: Int) async throws -> ClassOwnerDashboardData? { + try await repository.fetchOwnerDashboard(roomID: roomID) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift new file mode 100644 index 00000000..6b6d188b --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift @@ -0,0 +1,27 @@ +// +// ClassUseCaseImpl+Result.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import ClassDomainInterface +import Foundation + +extension ClassUseCaseImpl { + public func fetchResult(roomID: Int) async throws -> ClassResult { + try await repository.fetchResult(roomID: roomID) + } + + public func fetchPersonalResult(roomID: Int) async throws -> ClassAIPersonalFeedback { + try await repository.fetchPersonalResult(roomID: roomID) + } + + public func fetchInsight(roomID: Int) async throws -> ClassAIInsight { + try await repository.fetchInsight(roomID: roomID) + } + + public func retryInsight(roomID: Int) async throws { + try await repository.retryInsight(roomID: roomID) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Room.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Room.swift new file mode 100644 index 00000000..6af986d7 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Room.swift @@ -0,0 +1,51 @@ +// +// ClassUseCaseImpl+Room.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import ClassDomainInterface +import Foundation + +extension ClassUseCaseImpl { + public func fetchMyClasses() async throws -> [ClassRoom] { + try await repository.fetchMyClasses() + } + + public func createClass(_ creation: ClassCreation) async throws -> ClassRoom { + try await repository.createClass(creation) + } + + public func fetchClass(id: Int) async throws -> ClassRoom { + try await repository.fetchClass(id: id) + } + + public func fetchClass(joinCode: String) async throws -> ClassRoom { + try await repository.fetchClass(joinCode: joinCode) + } + + public func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview { + try await repository.fetchClassPreview(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 updateName(id: Int, name: String) async throws -> ClassRoom { + try await repository.updateName(id: id, name: name) + } + + public func deleteClass(id: Int) async throws { + try await repository.deleteClass(id: id) + } + + public func leaveClass(id: Int) async throws { + try await repository.leaveClass(id: id) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Ticket.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Ticket.swift new file mode 100644 index 00000000..b7fe8dff --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Ticket.swift @@ -0,0 +1,23 @@ +// +// ClassUseCaseImpl+Ticket.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import ClassDomainInterface +import Foundation + +extension ClassUseCaseImpl { + public func previewTicket(code: String) async throws -> ClassTicketPreview { + try await repository.previewTicket(code: code) + } + + public func registerTicket(code: String) async throws -> ClassTicket { + try await repository.registerTicket(code: code) + } + + public func fetchMyTicket() async throws -> ClassTicket { + try await repository.fetchMyTicket() + } +} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+TopicDraft.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+TopicDraft.swift new file mode 100644 index 00000000..2bfeda59 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+TopicDraft.swift @@ -0,0 +1,39 @@ +// +// ClassUseCaseImpl+TopicDraft.swift +// ClassDomain +// +// Created by Picke on 10/26/26. +// + +import ClassDomainInterface +import Foundation + +extension ClassUseCaseImpl { + public func createTopicDraft(filter: ClassTopicFilter) async throws -> ClassTopicDraft { + try await repository.createTopicDraft(filter: filter) + } + + public func fetchTopicDraft(id: Int) async throws -> ClassTopicDraft { + try await repository.fetchTopicDraft(id: id) + } + + public func suggestTopicDraft(id: Int, filter: ClassTopicFilter?) async throws -> ClassTopicDraft { + try await repository.suggestTopicDraft(id: id, filter: filter) + } + + public func selectTopicDraft(draftID: Int, batchNo: Int, candidateID: Int) async throws -> ClassTopicVersion { + try await repository.selectTopicDraft(draftID: draftID, batchNo: batchNo, candidateID: candidateID) + } + + public func validateTopicDraft( + draftID: Int, + baseVersionID: Int, + candidate: ClassTopicCandidate + ) async throws -> ClassTopicValidation { + try await repository.validateTopicDraft( + draftID: draftID, + baseVersionID: baseVersionID, + candidate: candidate + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl.swift index 23eed939..30add834 100644 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl.swift +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl.swift @@ -10,55 +10,8 @@ import ComposableArchitecture import Foundation public struct ClassUseCaseImpl: ClassInterface { - @Dependency(\.classRepository) private var repository + @Dependency(\.classRepository) 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(id: Int) async throws -> ClassRoom { - try await repository.fetchClass(id: id) - } - - public func fetchClass(joinCode: String) async throws -> ClassRoom { - try await repository.fetchClass(joinCode: joinCode) - } - - public func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview { - try await repository.fetchClassPreview(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) - } - - public func leaveClass(id: Int) async throws { - try await repository.leaveClass(id: id) - } - - public func fetchReport(roomID: Int) async throws -> ClassReport? { - try await repository.fetchReport(roomID: roomID) - } - - public func fetchOwnerDashboard(roomID: Int) async throws -> ClassOwnerDashboardData? { - try await repository.fetchOwnerDashboard(roomID: roomID) - } } diff --git a/Projects/Feature/Class/Sources/ClassAIChat/Reducer/ClassAIChatFeature.swift b/Projects/Feature/Class/Sources/ClassAIChat/Reducer/ClassAIChatFeature.swift index 54aa59d2..698eeb0f 100644 --- a/Projects/Feature/Class/Sources/ClassAIChat/Reducer/ClassAIChatFeature.swift +++ b/Projects/Feature/Class/Sources/ClassAIChat/Reducer/ClassAIChatFeature.swift @@ -168,7 +168,7 @@ public struct ClassAIChatFeature { case response } - @Dependency(\.classDebateUseCase) private var debateUseCase + @Dependency(\.classUseCase) private var debateUseCase public var body: some Reducer { Reduce { state, action in @@ -214,18 +214,18 @@ extension ClassAIChatFeature { do { let initial: ClassDebate if retryServerGeneration { - initial = try await debateUseCase.retry(classID: roomID) + initial = try await debateUseCase.retryDebate(classID: roomID) } else { do { - initial = try await debateUseCase.fetch(classID: roomID) + initial = try await debateUseCase.fetchDebate(classID: roomID) } catch ClassAIChatError.noSession { - initial = try await debateUseCase.start(classID: roomID) + initial = try await debateUseCase.startDebate(classID: roomID) } } var debate = initial while debate.status == .generating, !debate.canRetry, !Task.isCancelled { try await Task.sleep(for: .seconds(2)) - debate = try await debateUseCase.fetch(classID: roomID) + debate = try await debateUseCase.fetchDebate(classID: roomID) } guard !Task.isCancelled else { return } await send(.inner(.debateLoaded(requestID, .success(debate)))) @@ -298,7 +298,7 @@ extension ClassAIChatFeature { let round = debate.round return .run { send in do { - var result = try await debateUseCase.submitReply( + var result = try await debateUseCase.submitDebateReply( classID: roomID, round: round, content: text, @@ -306,7 +306,7 @@ extension ClassAIChatFeature { ) while result.status == .generating, !result.canRetry, !Task.isCancelled { try await Task.sleep(for: .seconds(2)) - result = try await debateUseCase.fetch(classID: roomID) + result = try await debateUseCase.fetchDebate(classID: roomID) } guard !Task.isCancelled else { return } await send(.inner(.replyLoaded(requestID, .success(result)))) diff --git a/Projects/Feature/Class/Sources/ClassAIVote/Reducer/ClassAIVoteFeature.swift b/Projects/Feature/Class/Sources/ClassAIVote/Reducer/ClassAIVoteFeature.swift index 52c5d12b..ea9f1235 100644 --- a/Projects/Feature/Class/Sources/ClassAIVote/Reducer/ClassAIVoteFeature.swift +++ b/Projects/Feature/Class/Sources/ClassAIVote/Reducer/ClassAIVoteFeature.swift @@ -176,7 +176,7 @@ public struct ClassAIVoteFeature { @Dependency(\.uuid) private var uuid @Dependency(\.battleUseCase) private var battleUseCase - @Dependency(\.classBattleUseCase) private var classBattleUseCase + @Dependency(\.classUseCase) private var classUseCase public var body: some Reducer { Reduce { state, action in @@ -253,7 +253,7 @@ extension ClassAIVoteFeature { let classOptionID: Int? = state.isLiveClassBattle ? state.classOptionIDs[optionID - 1] : nil return .run { [ battleUseCase, - classBattleUseCase, + classUseCase, instanceID = state.instanceID, battleID = state.battleID, classID = state.room.id, @@ -262,9 +262,9 @@ extension ClassAIVoteFeature { let result = await Result { if let classOptionID { let receipt = if isPostVote { - try await classBattleUseCase.submitPostVote(classID: classID, optionID: classOptionID) + try await classUseCase.submitPostVote(classID: classID, optionID: classOptionID) } else { - try await classBattleUseCase.submitPreVote(classID: classID, optionID: classOptionID) + try await classUseCase.submitPreVote(classID: classID, optionID: classOptionID) } return PreVoteResult(voteId: receipt.voteID, status: .created) } @@ -289,7 +289,7 @@ extension ClassAIVoteFeature { state.errorMessage = nil return .run { [ battleUseCase, - classBattleUseCase, + classUseCase, instanceID = state.instanceID, battleID = state.battleID, classID = state.room.id, @@ -297,10 +297,10 @@ extension ClassAIVoteFeature { ] send in let result = await Result { if isLiveClassBattle { - let session = try await classBattleUseCase.fetchBattle(classID: classID) + let session = try await classUseCase.fetchBattle(classID: classID) let history: ClassVoteHistory? do { - history = try await classBattleUseCase.fetchMyVotes(classID: classID) + history = try await classUseCase.fetchMyVotes(classID: classID) } catch ClassBattleError.voteNotFound { history = nil } diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift index dcd1c531..b1452489 100644 --- a/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift @@ -177,7 +177,7 @@ public struct ClassBattleFlowFeature { } @Dependency(\.uuid) private var uuid - @Dependency(\.classBattleUseCase) private var classBattleUseCase + @Dependency(\.classUseCase) private var classUseCase @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.audioPlayer) private var audioPlayer @@ -271,7 +271,7 @@ extension ClassBattleFlowFeature { let optionID = state.selectedOptionID let preVoteOptionID = state.voteHistory?.preVote.optionID let draft = state.draft.trimmingCharacters(in: .whitespacesAndNewlines) - return .run { [useCase = classBattleUseCase] send in + return .run { [useCase = classUseCase] send in let result = await Result { switch step { case .none: @@ -330,7 +330,7 @@ extension ClassBattleFlowFeature { state.loadRequestID = requestID state.viewState = .loading state.errorMessage = nil - return .run { [useCase = classBattleUseCase] send in + return .run { [useCase = classUseCase] send in let result = await Result { let session = try await useCase.fetchBattle(classID: classID) let history = session.battle.currentStep == .none @@ -461,7 +461,7 @@ extension ClassBattleFlowFeature { state.nextPerspectiveCursor = nil state.perspectives = [] } - return .run { [useCase = classBattleUseCase] send in + return .run { [useCase = classUseCase] send in let result = await Result { try await useCase.fetchPerspectives( classID: classID, diff --git a/Projects/Feature/Class/Sources/ClassJoin/Reducer/ClassJoinFeature.swift b/Projects/Feature/Class/Sources/ClassJoin/Reducer/ClassJoinFeature.swift index 91e92f76..d7bda912 100644 --- a/Projects/Feature/Class/Sources/ClassJoin/Reducer/ClassJoinFeature.swift +++ b/Projects/Feature/Class/Sources/ClassJoin/Reducer/ClassJoinFeature.swift @@ -60,6 +60,11 @@ public struct ClassJoinFeature { mode == .code && joinCode.count == 6 && !isLoading } + public var codeCharacters: [String] { + let characters = Array(joinCode.prefix(6)).map(String.init) + return characters + Array(repeating: "-", count: max(0, 6 - characters.count)) + } + public var primaryButtonTitle: String { switch mode { case .code, .nickname: return "클래스 참여하기" @@ -69,14 +74,13 @@ public struct ClassJoinFeature { public var navigationCenterTitle: String? { switch mode { - case .code: return nil - case .nickname: return "클래스 참여" + case .code, .nickname: return "클래스 참여" case .ownerName: return "이름 입력" } } public var navigationLeadingTitle: String? { - mode == .code ? "클래스 참여" : nil + nil } public var nicknamePlaceholder: String { @@ -151,7 +155,6 @@ public struct ClassJoinFeature { } @Dependency(\.classUseCase) private var classUseCase - @Dependency(\.classMemberUseCase) private var classMemberUseCase @Dependency(\.uuid) private var uuid public var body: some Reducer { @@ -284,9 +287,9 @@ extension ClassJoinFeature { state.activeOwnerRequestID = requestID state.errorMessage = nil state.nicknameErrorMessage = nil - return .run { [classMemberUseCase, room = state.preview] send in + return .run { [classUseCase, room = state.preview] send in let result = await Result { () async throws -> ClassRoom in - _ = try await classMemberUseCase.updateMyDisplayName(classID: roomID, name: nickname) + _ = try await classUseCase.updateMyDisplayName(classID: roomID, name: nickname) guard let room else { throw ClassError.invalidCode } return room } diff --git a/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift b/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift index 9cb78e2a..13c6b38e 100644 --- a/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift +++ b/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift @@ -15,6 +15,7 @@ import SwiftUI public struct ClassJoinView: View { @Bindable public var store: StoreOf @FocusState private var isCodeFocused: Bool + @FocusState private var isNicknameFocused: Bool public init(store: StoreOf) { self.store = store @@ -31,7 +32,7 @@ public struct ClassJoinView: View { .foregroundStyle(.gray500) ScrollView { - VStack(alignment: .leading, spacing: store.mode == .code ? 26 : 24) { + VStack(alignment: .leading, spacing: store.mode == .code ? 20 : 24) { titleSection() switch store.mode { case .code: @@ -41,34 +42,37 @@ public struct ClassJoinView: View { } } .padding(16) - .padding(.top, store.mode == .code ? 6 : 0) .frame(maxWidth: .infinity, alignment: .leading) } .scrollIndicators(.hidden) .scrollBounceBehavior(.basedOnSize) - bottomActions() + bottomAction .padding(.horizontal, 16) .padding(.vertical, 16) } .frame(maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .ignoresSafeArea(.container, edges: store.mode == .code ? .bottom : []) - .screenBackground(.surfaceBeigeDefault) + .screenBackground(.white) .hidesSystemBars() + .onAppear { + isCodeFocused = store.mode == .code + isNicknameFocused = store.mode != .code + } } } private extension ClassJoinView { @ViewBuilder func titleSection() -> some View { - VStack(alignment: .leading, spacing: store.mode == .code ? 14 : 8) { + VStack(alignment: .leading, spacing: store.mode == .code ? 6 : 8) { Text(store.mode == .code ? "공유받은 코드로\n클래스에 참여하세요" : "이름을 입력해주세요") .pretendardFont(.semiBold24) .lineSpacing(store.mode == .code ? 7 : 2.4) .foregroundStyle(.gray800) Text(store.mode == .code - ? "선생님이나 모임장이 전달한\n6자리 코드를 입력해 주세요." + ? "전달받은 6자리 코드를 입력해 주세요." : "이 클래스에서만 보이는 이름이에요.\n입장 후에도 수정할 수 있어요.") .pretendardFont(.bodyMedium) .lineSpacing(4) @@ -78,58 +82,48 @@ private extension ClassJoinView { @ViewBuilder func codeSection() -> some View { - VStack(alignment: .leading, spacing: 29) { - VStack(alignment: .leading, spacing: 12) { - Text("참여 코드") - .pretendardFont(.headingMedium) - .foregroundStyle(.gray800) - - TextField( - "", - text: $store.joinCode, - prompt: Text("6자리 코드를 입력해 주세요") - .font(.pretendardFontFamily(family: .Regular, size: 14)) - .tracking(0) - .foregroundStyle(.gray300) - ) + VStack(alignment: .leading, spacing: 8) { + ZStack { + HStack(spacing: 14) { + ForEach(0..<6, id: \.self) { index in + Text(store.codeCharacters[index]) + .pretendardFont(.headingMedium) + .foregroundStyle(.gray800) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(index < store.joinCode.count ? .bgSubtle : .white) + .overlay { + RoundedRectangle(cornerRadius: 2) + .strokeBorder(codeBorderColor(at: index), lineWidth: 1) + } + } + } + + TextField("", text: $store.joinCode) .textInputAutocapitalization(.characters) .autocorrectionDisabled() .keyboardType(.asciiCapable) .textContentType(.oneTimeCode) .focused($isCodeFocused) .accessibilityLabel("참여 코드, 6자리") - .tracking(4) - .pickeTextField( - height: 52, - font: .headingLarge - ) - .overlay { - if store.errorMessage != nil { - RoundedRectangle(cornerRadius: 2) - .strokeBorder(.borderErrorDefault, lineWidth: 1) - } - } - - if let message = store.errorMessage { - errorLabel(message) - } + .foregroundStyle(.clear) + .tint(.clear) + .frame(maxWidth: .infinity, maxHeight: .infinity) } + .frame(height: 50) - VStack(alignment: .leading, spacing: 8) { - Text("코드를 찾을 수 없나요?") - .pretendardFont(.headingSmall) - .foregroundStyle(.gray800) - - Text("클래스를 만든 선생님이나 모임장에게\n참여 코드를 확인해 주세요.") - .pretendardFont(.bodySmall) - .foregroundStyle(.gray400) + if let message = store.errorMessage { + errorLabel(message) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(16) - .background(.beige200) } } + func codeBorderColor(at index: Int) -> Color { + if store.errorMessage != nil { return .borderErrorDefault } + if isCodeFocused && index == min(store.joinCode.count, 5) { return .secondary500 } + return .gray50 + } + @ViewBuilder func nicknameSection() -> some View { VStack(alignment: .leading, spacing: 8) { @@ -156,6 +150,7 @@ private extension ClassJoinView { TextField("", text: $store.nickname, prompt: Text(store.nicknamePlaceholder).foregroundStyle(.gray300)) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .focused($isNicknameFocused) .accessibilityLabel("클래스에서 사용할 이름") .pickeTextField(height: 52) .overlay { @@ -173,22 +168,11 @@ private extension ClassJoinView { .fixedSize(horizontal: false, vertical: true) } - @ViewBuilder - func bottomActions() -> some View { - VStack(spacing: 8) { - Button { send(store.mode == .code ? .findTapped : .joinTapped) } label: { - Text(store.primaryButtonTitle) - } - .ctaButtonStyle(.primary, size: .large, height: 52) - .disabled(store.mode == .code ? !store.canFindClass : !store.canJoin) - - if store.mode == .code { - Button { send(.createClassTapped) } label: { - Text("클래스 새로 만들기") - } - .ctaButtonStyle(.primary, size: .large, height: 52) - .disabled(store.isLoading) - } + var bottomAction: some View { + Button { send(store.mode == .code ? .findTapped : .joinTapped) } label: { + Text(store.primaryButtonTitle) } + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(store.mode == .code ? !store.canFindClass : !store.canJoin) } } diff --git a/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift b/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift index 272ef0fd..c9c6e3c4 100644 --- a/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift +++ b/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift @@ -74,7 +74,7 @@ public struct ClassMyOpinionsFeature { case fetch } - @Dependency(\.classBattleUseCase) private var classBattleUseCase + @Dependency(\.classUseCase) private var classUseCase public var body: some Reducer { Reduce { state, action in @@ -91,7 +91,7 @@ public struct ClassMyOpinionsFeature { guard let classID = state.classID else { return .none } state.isLoading = true state.errorMessage = nil - return .run { [useCase = classBattleUseCase] send in + return .run { [useCase = classUseCase] send in let result = await Result { try await useCase.fetchMyPerspectives(classID: classID) } .mapError { ($0 as? ClassBattleError) ?? .network($0.localizedDescription) } await send(.inner(.perspectivesResponse(result))) diff --git a/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift b/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift index 81db4533..3e87f44c 100644 --- a/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift +++ b/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift @@ -376,7 +376,7 @@ public struct ClassOwnerDashboardFeature { case openFeedback(ClassOwnerMember) } - @Dependency(\.classResultUseCase) private var classResultUseCase + @Dependency(\.classUseCase) private var classUseCase @Dependency(\.uuid) private var uuid public var body: some Reducer { @@ -495,12 +495,12 @@ extension ClassOwnerDashboardFeature { state.requestID = requestID state.sourceViewState = .loading let fetch = Effect.run { [ - classResultUseCase, + classUseCase, instanceID = state.instanceID, roomID = state.room.id ] send in let result = await Result { - try await classResultUseCase.fetch(roomID: roomID) + try await classUseCase.fetchResult(roomID: roomID) } .mapError(ClassError.from) await send(.inner(.resultResponse( @@ -522,14 +522,14 @@ extension ClassOwnerDashboardFeature { state.insightRequestID = requestID state.insightError = nil state.isGeneratingInsight = true - return .run { [classResultUseCase, instanceID = state.instanceID, roomID = state.room.id] send in + return .run { [classUseCase, instanceID = state.instanceID, roomID = state.room.id] send in var wasUnknown = false var wasCancelled = false let result = await Result { if retryServerGeneration { - try await classResultUseCase.retryInsight(roomID: roomID) + try await classUseCase.retryInsight(roomID: roomID) } - return try await ClassAIFeedbackResult.serverInsight(classResultUseCase.fetchInsight(roomID: roomID)) + return try await ClassAIFeedbackResult.serverInsight(classUseCase.fetchInsight(roomID: roomID)) } .mapError { error in wasCancelled = error is CancellationError @@ -551,11 +551,11 @@ extension ClassOwnerDashboardFeature { state.feedbackRequestID = requestID state.feedbackError = nil state.isGeneratingFeedback = true - return .run { [classResultUseCase, instanceID = state.instanceID, roomID = state.room.id] send in + return .run { [classUseCase, instanceID = state.instanceID, roomID = state.room.id] send in var wasUnknown = false var wasCancelled = false let result = await Result { - try await ClassAIFeedbackResult.personal(classResultUseCase.fetchPersonal(roomID: roomID)) + try await ClassAIFeedbackResult.personal(classUseCase.fetchPersonalResult(roomID: roomID)) } .mapError { error in wasCancelled = error is CancellationError diff --git a/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift index e67dcecb..0472d4fa 100644 --- a/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift +++ b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift @@ -133,7 +133,6 @@ public struct ClassRecommendFeature { } @Dependency(\.classUseCase) private var classUseCase - @Dependency(\.classTopicDraftUseCase) private var topicDraftUseCase public var body: some Reducer { Reduce { state, action in @@ -255,9 +254,9 @@ extension ClassRecommendFeature { case let .createDraft(filter): state.isLoading = true state.viewState = .loading - return .run { [topicDraftUseCase] send in + return .run { [classUseCase] send in let result = await Result { - try await topicDraftUseCase.create(filter: filter) + try await classUseCase.createTopicDraft(filter: filter) } .mapError(ClassError.from) guard !Task.isCancelled else { return } @@ -268,12 +267,12 @@ extension ClassRecommendFeature { case let .suggest(draftID): state.isLoading = true state.viewState = .loading - return .run { [topicDraftUseCase] send in + return .run { [classUseCase] send in let result = await Result { - let draft = try await topicDraftUseCase.suggest(id: draftID, filter: nil) + let draft = try await classUseCase.suggestTopicDraft(id: draftID, filter: nil) guard !Task.isCancelled else { throw CancellationError() } await send(.inner(.suggestionStarted(draft))) - return try await Self.pollSuggestion(draft: draft, useCase: topicDraftUseCase) + return try await Self.pollSuggestion(draft: draft, useCase: classUseCase) } .mapError(ClassError.from) guard !Task.isCancelled else { return } @@ -284,10 +283,10 @@ extension ClassRecommendFeature { case let .poll(draftID): state.isLoading = true state.viewState = .loading - return .run { [topicDraftUseCase] send in + return .run { [classUseCase] send in let result = await Result { - let draft = try await topicDraftUseCase.fetch(id: draftID) - return try await Self.pollSuggestion(draft: draft, useCase: topicDraftUseCase) + let draft = try await classUseCase.fetchTopicDraft(id: draftID) + return try await Self.pollSuggestion(draft: draft, useCase: classUseCase) } .mapError(ClassError.from) guard !Task.isCancelled else { return } @@ -297,9 +296,9 @@ extension ClassRecommendFeature { case let .select(draftID, batchNo, candidateID): state.isLoading = true - return .run { [topicDraftUseCase] send in + return .run { [classUseCase] send in let result = await Result { - try await topicDraftUseCase.select(draftID: draftID, batchNo: batchNo, candidateID: candidateID) + try await classUseCase.selectTopicDraft(draftID: draftID, batchNo: batchNo, candidateID: candidateID) } .mapError(ClassError.from) guard !Task.isCancelled else { return } @@ -369,12 +368,12 @@ extension ClassRecommendFeature { private static func pollSuggestion( draft: ClassTopicDraft, - useCase: any ClassTopicDraftInterface + useCase: any ClassInterface ) async throws -> ClassTopicDraft { var current = draft for _ in 0 ..< 90 where current.suggestStatus == "GENERATING" { try await Task.sleep(for: .seconds(2)) - current = try await useCase.fetch(id: draft.id) + current = try await useCase.fetchTopicDraft(id: draft.id) } guard current.suggestStatus != "GENERATING" else { throw ClassError.unknown("AI 주제 생성 시간이 초과됐어요. 다시 확인해 주세요.") diff --git a/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift b/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift index 41b0d079..a63fa5b3 100644 --- a/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift +++ b/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift @@ -170,7 +170,7 @@ public struct ClassReportFeature { case generate(UUID) } - @Dependency(\.classResultUseCase) private var classResultUseCase + @Dependency(\.classUseCase) private var classUseCase @Dependency(\.uuid) private var uuid public var body: some Reducer { @@ -244,12 +244,12 @@ extension ClassReportFeature { state.requestID = requestID state.sourceViewState = .loading let fetch = Effect.run { [ - classResultUseCase, + classUseCase, instanceID = state.instanceID, roomID = state.room.id ] send in let result = await Result { - try await classResultUseCase.fetch(roomID: roomID) + try await classUseCase.fetchResult(roomID: roomID) } .mapError(ClassError.from) await send(.inner(.resultResponse( @@ -266,11 +266,11 @@ extension ClassReportFeature { state.feedbackRequestID = requestID state.feedbackError = nil state.isGeneratingFeedback = true - return .run { [classResultUseCase, instanceID = state.instanceID, roomID = state.room.id] send in + return .run { [classUseCase, instanceID = state.instanceID, roomID = state.room.id] send in var wasUnknown = false var wasCancelled = false let result = await Result { - try await ClassAIFeedbackResult.personal(classResultUseCase.fetchPersonal(roomID: roomID)) + try await ClassAIFeedbackResult.personal(classUseCase.fetchPersonalResult(roomID: roomID)) } .mapError { error in wasCancelled = error is CancellationError diff --git a/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift index 6afb6a2e..eb8123c8 100644 --- a/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift +++ b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift @@ -113,7 +113,7 @@ public struct ClassTicketFeature { case existingTicket(UUID) } - @Dependency(\.classTicketUseCase) private var ticketUseCase + @Dependency(\.classUseCase) private var ticketUseCase public var body: some Reducer { BindingReducer() @@ -158,7 +158,7 @@ public struct ClassTicketFeature { let submittedCode = state.code.trimmingCharacters(in: .whitespacesAndNewlines) return .run { [ticketUseCase] send in let result: Result = await Result { - try await ticketUseCase.register(code: submittedCode) + try await ticketUseCase.registerTicket(code: submittedCode) } .mapError(Self.mapError) await send(.registrationResponse(result)) @@ -190,7 +190,7 @@ public struct ClassTicketFeature { let submittedCode = state.code.trimmingCharacters(in: .whitespacesAndNewlines) return .run { [ticketUseCase] send in let result: Result = await Result { - try await ticketUseCase.preview(code: submittedCode) + try await ticketUseCase.previewTicket(code: submittedCode) } .mapError(Self.mapError) await send(.checkResponse(result)) diff --git a/Projects/Service/API/Sources/Base/PieckeDomain.swift b/Projects/Service/API/Sources/Base/PieckeDomain.swift index 88c40bb5..51ff7046 100644 --- a/Projects/Service/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Service/API/Sources/Base/PieckeDomain.swift @@ -22,6 +22,7 @@ public enum PieckeDomain { case notification case device case ads + case classroom } extension PieckeDomain: PickeDomainType { @@ -55,6 +56,8 @@ extension PieckeDomain: PickeDomainType { return "api/v1/devices" case .ads: return "api/v1/ads" + case .classroom: + return "api/v1/" } } } diff --git a/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift b/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift new file mode 100644 index 00000000..2aefadcf --- /dev/null +++ b/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift @@ -0,0 +1,23 @@ +public enum ClassBattleAPI { + case detail(classID: Int) + case preVote(classID: Int) + case ttsComplete(classID: Int) + case postVote(classID: Int) + case myVotes(classID: Int) + case voteStats(classID: Int) + case perspectives(classID: Int) + case myPerspectives(classID: Int) + + public var description: String { + switch self { + case let .detail(id): return "classes/\(id)/battle" + case let .preVote(id): return "classes/\(id)/battle/votes/pre" + case let .ttsComplete(id): return "classes/\(id)/battle/votes/tts-complete" + case let .postVote(id): return "classes/\(id)/battle/votes/post" + case let .myVotes(id): return "classes/\(id)/battle/votes/me" + case let .voteStats(id): return "classes/\(id)/battle/vote-stats" + case let .perspectives(id): return "classes/\(id)/battle/perspectives" + case let .myPerspectives(id): return "classes/\(id)/battle/perspectives/me" + } + } +} diff --git a/Projects/Service/API/Sources/Class/Debate/ClassDebateAPI.swift b/Projects/Service/API/Sources/Class/Debate/ClassDebateAPI.swift new file mode 100644 index 00000000..f9808d76 --- /dev/null +++ b/Projects/Service/API/Sources/Class/Debate/ClassDebateAPI.swift @@ -0,0 +1,13 @@ +public enum ClassDebateAPI { + case debate(classID: Int) + case replies(classID: Int) + case retry(classID: Int) + + public var description: String { + switch self { + case let .debate(id): return "classes/\(id)/battle/debate" + case let .replies(id): return "classes/\(id)/battle/debate/replies" + case let .retry(id): return "classes/\(id)/battle/debate/retry" + } + } +} diff --git a/Projects/Service/API/Sources/Class/Member/ClassMemberAPI.swift b/Projects/Service/API/Sources/Class/Member/ClassMemberAPI.swift new file mode 100644 index 00000000..9a3365ef --- /dev/null +++ b/Projects/Service/API/Sources/Class/Member/ClassMemberAPI.swift @@ -0,0 +1,13 @@ +public enum ClassMemberAPI { + case members(classID: Int) + case me(classID: Int) + case member(classID: Int, memberID: Int) + + public var description: String { + switch self { + case let .members(id): return "classes/\(id)/members" + case let .me(id): return "classes/\(id)/members/me" + case let .member(id, memberID): return "classes/\(id)/members/\(memberID)" + } + } +} diff --git a/Projects/Service/API/Sources/Class/Recommendation/ClassBattleRecommendationAPI.swift b/Projects/Service/API/Sources/Class/Recommendation/ClassBattleRecommendationAPI.swift new file mode 100644 index 00000000..d9a2caca --- /dev/null +++ b/Projects/Service/API/Sources/Class/Recommendation/ClassBattleRecommendationAPI.swift @@ -0,0 +1,9 @@ +public enum ClassBattleRecommendationAPI { + case recommendations + + public var description: String { + switch self { + case .recommendations: return "class-battle-recommendations" + } + } +} diff --git a/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift b/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift new file mode 100644 index 00000000..62b20f2d --- /dev/null +++ b/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift @@ -0,0 +1,15 @@ +public enum ClassResultAPI { + case result(classID: Int) + case myReport(classID: Int) + case insight(classID: Int) + case retryInsight(classID: Int) + + public var description: String { + switch self { + case let .result(id): return "classes/\(id)/result" + case let .myReport(id): return "classes/\(id)/result/my-report" + case let .insight(id): return "classes/\(id)/result/insight" + case let .retryInsight(id): return "classes/\(id)/result/insight/retry" + } + } +} diff --git a/Projects/Service/API/Sources/Class/Room/ClassroomAPI.swift b/Projects/Service/API/Sources/Class/Room/ClassroomAPI.swift new file mode 100644 index 00000000..746119ef --- /dev/null +++ b/Projects/Service/API/Sources/Class/Room/ClassroomAPI.swift @@ -0,0 +1,17 @@ +public enum ClassroomAPI { + case classes + case detail(classID: Int) + case joinPreview + case join + case leave(classID: Int) + + public var description: String { + switch self { + case .classes: return "classes" + case let .detail(id): return "classes/\(id)" + case .joinPreview: return "classes/join/preview" + case .join: return "classes/join" + case let .leave(id): return "classes/\(id)/members/me" + } + } +} diff --git a/Projects/Service/API/Sources/Class/Ticket/ClassTicketAPI.swift b/Projects/Service/API/Sources/Class/Ticket/ClassTicketAPI.swift new file mode 100644 index 00000000..7d0e908a --- /dev/null +++ b/Projects/Service/API/Sources/Class/Ticket/ClassTicketAPI.swift @@ -0,0 +1,13 @@ +public enum ClassTicketAPI { + case preview + case register + case me + + public var description: String { + switch self { + case .preview: return "class-tickets/preview" + case .register: return "class-tickets/register" + case .me: return "class-tickets/me" + } + } +} diff --git a/Projects/Service/API/Sources/Class/TopicDraft/ClassTopicDraftAPI.swift b/Projects/Service/API/Sources/Class/TopicDraft/ClassTopicDraftAPI.swift new file mode 100644 index 00000000..c201330d --- /dev/null +++ b/Projects/Service/API/Sources/Class/TopicDraft/ClassTopicDraftAPI.swift @@ -0,0 +1,17 @@ +public enum ClassTopicDraftAPI { + case drafts + case detail(draftID: Int) + case suggestions(draftID: Int) + case selection(draftID: Int) + case validations(draftID: Int) + + public var description: String { + switch self { + case .drafts: return "class-topic-drafts" + case let .detail(id): return "class-topic-drafts/\(id)" + case let .suggestions(id): return "class-topic-drafts/\(id)/suggestions" + case let .selection(id): return "class-topic-drafts/\(id)/selection" + case let .validations(id): return "class-topic-drafts/\(id)/validations" + } + } +} diff --git a/Projects/Service/APIEndpoint/Project.swift b/Projects/Service/APIEndpoint/Project.swift index 4750f95e..df5a4d0d 100644 --- a/Projects/Service/APIEndpoint/Project.swift +++ b/Projects/Service/APIEndpoint/Project.swift @@ -24,7 +24,9 @@ let project = Project.makeModule( .service(.api), // Auth 엔드포인트가 SocialType 을 경로에 쓴다. .domain(.auth, .interface), + .domain(.classroom, .interface), + .core(.coreUtility), .core(.network, .interface), ], hasTests: true -) \ No newline at end of file +) diff --git a/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift b/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift new file mode 100644 index 00000000..ef3c7455 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift @@ -0,0 +1,54 @@ +import Alamofire +import API +import Foundation +import PickeNetwork + +public enum ClassBattleService { + case detail(classID: Int) + case preVote(classID: Int, optionID: Int) + case ttsComplete(classID: Int) + case postVote(classID: Int, optionID: Int) + case myVotes(classID: Int) + case voteStats(classID: Int) + case publishPerspective(classID: Int, content: String) + case perspectives(classID: Int, cursor: String?, size: Int?, optionID: Int?, sort: String?) + case myPerspectives(classID: Int) +} + +extension ClassBattleService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.classroom } + + public var path: String { + switch self { + case let .detail(classID): return ClassBattleAPI.detail(classID: classID).description + case let .preVote(classID, _): return ClassBattleAPI.preVote(classID: classID).description + case let .ttsComplete(classID): return ClassBattleAPI.ttsComplete(classID: classID).description + case let .postVote(classID, _): return ClassBattleAPI.postVote(classID: classID).description + case let .myVotes(classID): return ClassBattleAPI.myVotes(classID: classID).description + case let .voteStats(classID): return ClassBattleAPI.voteStats(classID: classID).description + case let .publishPerspective(classID, _), let .perspectives(classID, _, _, _, _): + return ClassBattleAPI.perspectives(classID: classID).description + case let .myPerspectives(classID): return ClassBattleAPI.myPerspectives(classID: classID).description + } + } + + public var method: HTTPMethod { + switch self { + case .detail, .myVotes, .voteStats, .perspectives, .myPerspectives: return .get + case .preVote, .ttsComplete, .postVote, .publishPerspective: return .post + } + } + + public var parameters: (any Encodable & Sendable)? { + switch self { + case let .preVote(_, optionID), let .postVote(_, optionID): + return VoteBody(optionId: optionID) + case let .publishPerspective(_, content): + return PerspectiveBody(content: content) + case let .perspectives(_, cursor, size, optionID, sort): + return PerspectivesQuery(cursor: cursor, size: size, optionId: optionID, sort: sort) + case .detail, .ttsComplete, .myVotes, .voteStats, .myPerspectives: + return nil + } + } +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift new file mode 100644 index 00000000..d46892c6 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift @@ -0,0 +1,16 @@ +import Foundation + +struct VoteBody: Encodable, Sendable { + let optionId: Int +} + +struct PerspectiveBody: Encodable, Sendable { + let content: String +} + +struct PerspectivesQuery: Encodable, Sendable { + let cursor: String? + let size: Int? + let optionId: Int? + let sort: String? +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Debate/ClassDebateService.swift b/Projects/Service/APIEndpoint/Sources/Class/Debate/ClassDebateService.swift new file mode 100644 index 00000000..7f6843f0 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Debate/ClassDebateService.swift @@ -0,0 +1,41 @@ +import Alamofire +import API +import Foundation +import PickeNetwork + +public enum ClassDebateService { + case fetch(Int) + case start(Int) + case submitReply(classID: Int, round: Int, content: String, requestKey: UUID) + case retry(Int) +} + +extension ClassDebateService: PickeDataRequest { + public var domain: any PickeDomainType { + PieckeDomain.classroom + } + + public var path: String { + switch self { + case let .fetch(id), let .start(id): return ClassDebateAPI.debate(classID: id).description + case let .submitReply(classID, _, _, _): return ClassDebateAPI.replies(classID: classID).description + case let .retry(id): return ClassDebateAPI.retry(classID: id).description + } + } + + public var method: HTTPMethod { + switch self { + case .fetch: return .get + case .start, .submitReply, .retry: return .post + } + } + + public var parameters: (any Encodable & Sendable)? { + switch self { + case let .submitReply(_, round, content, requestKey): + return ReplyBody(roundIndex: round, content: content, requestKey: requestKey.uuidString) + case .fetch, .start, .retry: + return nil + } + } +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Debate/Request/ClassDebateRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Debate/Request/ClassDebateRequests.swift new file mode 100644 index 00000000..d2e9cdfa --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Debate/Request/ClassDebateRequests.swift @@ -0,0 +1,7 @@ +import Foundation + +struct ReplyBody: Encodable, Sendable { + let roundIndex: Int + let content: String + let requestKey: String +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Member/ClassMemberService.swift b/Projects/Service/APIEndpoint/Sources/Class/Member/ClassMemberService.swift new file mode 100644 index 00000000..3b7cb90d --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Member/ClassMemberService.swift @@ -0,0 +1,40 @@ +import Alamofire +import API +import Foundation +import PickeNetwork + +public enum ClassMemberService { + case members(classID: Int) + case updateMyName(classID: Int, name: String) + case remove(classID: Int, memberID: Int) +} + +extension ClassMemberService: PickeDataRequest { + public var domain: any PickeDomainType { + PieckeDomain.classroom + } + + public var path: String { + switch self { + case let .members(classID): return ClassMemberAPI.members(classID: classID).description + case let .updateMyName(classID, _): return ClassMemberAPI.me(classID: classID).description + case let .remove(classID, memberID): + return ClassMemberAPI.member(classID: classID, memberID: memberID).description + } + } + + public var method: HTTPMethod { + switch self { + case .members: return .get + case .updateMyName: return .patch + case .remove: return .delete + } + } + + public var parameters: (any Encodable & Sendable)? { + switch self { + case .members, .remove: return nil + case let .updateMyName(_, name): return ClassMemberNameBody(displayName: name) + } + } +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Member/Request/ClassMemberRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Member/Request/ClassMemberRequests.swift new file mode 100644 index 00000000..cb71c36e --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Member/Request/ClassMemberRequests.swift @@ -0,0 +1,5 @@ +import Foundation + +struct ClassMemberNameBody: Encodable, Sendable { + let displayName: String +} diff --git a/Projects/Domain/ClassDomain/Sources/Service/ClassBattleRecommendationService.swift b/Projects/Service/APIEndpoint/Sources/Class/Recommendation/ClassBattleRecommendationService.swift similarity index 56% rename from Projects/Domain/ClassDomain/Sources/Service/ClassBattleRecommendationService.swift rename to Projects/Service/APIEndpoint/Sources/Class/Recommendation/ClassBattleRecommendationService.swift index 2bd9d028..223dd3cb 100644 --- a/Projects/Domain/ClassDomain/Sources/Service/ClassBattleRecommendationService.swift +++ b/Projects/Service/APIEndpoint/Sources/Class/Recommendation/ClassBattleRecommendationService.swift @@ -4,24 +4,24 @@ import ClassDomainInterface import Foundation import PickeNetwork -enum ClassBattleRecommendationService { +public enum ClassBattleRecommendationService { case recommendations(filter: ClassTopicFilter) } extension ClassBattleRecommendationService: PickeDataRequest { - var domain: any PickeDomainType { - ClassBattleRecommendationDomain() + public var domain: any PickeDomainType { + PieckeDomain.classroom } - var path: String { - "class-battle-recommendations" + public var path: String { + ClassBattleRecommendationAPI.recommendations.description } - var method: HTTPMethod { + public var method: HTTPMethod { .get } - var parameters: (any Encodable & Sendable)? { + public var parameters: (any Encodable & Sendable)? { switch self { case let .recommendations(filter): return ClassBattleRecommendationQuery( @@ -33,22 +33,6 @@ extension ClassBattleRecommendationService: PickeDataRequest { } } -private struct ClassBattleRecommendationDomain: PickeDomainType { - var baseURLString: String { - PieckeDomain.battle.baseURLString - } - - var url: String { - "api/v1/" - } -} - -private struct ClassBattleRecommendationQuery: Encodable, Sendable { - let difficulty: String - let categories: String - let keyword: String? -} - private extension ClassAudienceLevel { var apiValue: String { switch self { diff --git a/Projects/Service/APIEndpoint/Sources/Class/Recommendation/Request/ClassBattleRecommendationRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Recommendation/Request/ClassBattleRecommendationRequests.swift new file mode 100644 index 00000000..492f787d --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Recommendation/Request/ClassBattleRecommendationRequests.swift @@ -0,0 +1,8 @@ +import Foundation +import ClassDomainInterface + +struct ClassBattleRecommendationQuery: Encodable, Sendable { + let difficulty: String + let categories: String + let keyword: String? +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift b/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift new file mode 100644 index 00000000..d282d4a7 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift @@ -0,0 +1,36 @@ +import Alamofire +import API +import PickeNetwork + +public enum ClassResultService { + case result(Int) + case myReport(Int) + case insight(Int) + case retryInsight(Int) +} + +extension ClassResultService: PickeDataRequest { + public var domain: any PickeDomainType { + PieckeDomain.classroom + } + + public var path: String { + switch self { + case let .result(id): return ClassResultAPI.result(classID: id).description + case let .myReport(id): return ClassResultAPI.myReport(classID: id).description + case let .insight(id): return ClassResultAPI.insight(classID: id).description + case let .retryInsight(id): return ClassResultAPI.retryInsight(classID: id).description + } + } + + public var method: HTTPMethod { + switch self { + case .result, .myReport, .insight: return .get + case .retryInsight: return .post + } + } + + public var parameters: (any Encodable & Sendable)? { + nil + } +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Room/ClassroomService.swift b/Projects/Service/APIEndpoint/Sources/Class/Room/ClassroomService.swift new file mode 100644 index 00000000..74b634d4 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Room/ClassroomService.swift @@ -0,0 +1,63 @@ +import Alamofire +import API +import ClassDomainInterface +import Foundation +import PickeCoreUtility +import PickeNetwork + +public enum ClassroomService { + case myClasses + case create(ClassCreation) + case detail(Int) + case updateDeadline(id: Int, deadline: Date) + case updateName(id: Int, name: String) + case delete(Int) + case joinPreview(String) + case join(code: String, nickname: String) + case leave(Int) +} + +extension ClassroomService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.classroom } + + public var path: String { + switch self { + case .myClasses, .create: + return ClassroomAPI.classes.description + case let .detail(id), let .delete(id), let .updateDeadline(id, _), let .updateName(id, _): + return ClassroomAPI.detail(classID: id).description + case .joinPreview: + return ClassroomAPI.joinPreview.description + case .join: + return ClassroomAPI.join.description + case let .leave(id): + return ClassroomAPI.leave(classID: id).description + } + } + + public var method: HTTPMethod { + switch self { + case .myClasses, .detail, .joinPreview: return .get + case .create, .join: return .post + case .updateDeadline, .updateName: return .patch + case .delete, .leave: return .delete + } + } + + public var parameters: (any Encodable & Sendable)? { + switch self { + case let .create(creation): + return CreateClassBody(creation: creation) + case let .updateDeadline(_, deadline): + return UpdateClassBody(name: nil, deadline: deadline.formatted(.serverDateTime)) + case let .updateName(_, name): + return UpdateClassBody(name: name, deadline: nil) + case let .joinPreview(code): + return ClassJoinPreviewQuery(inviteCode: code) + case let .join(code, nickname): + return JoinClassBody(inviteCode: code, displayName: nickname) + case .myClasses, .detail, .delete, .leave: + return nil + } + } +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift new file mode 100644 index 00000000..095b46d8 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift @@ -0,0 +1,39 @@ +import Foundation +import ClassDomainInterface +import PickeCoreUtility + +struct CreateClassBody: Encodable, Sendable { + let name: String + let battleId: Int? + let topicVersionId: Int? + let deadline: String? + let commentRequired: Bool + let ownerParticipates: Bool + let ownerDisplayName: String? + + init(creation: ClassCreation) { + name = creation.name + battleId = creation.battleId + topicVersionId = creation.topicVersionId + deadline = creation.deadline == .distantFuture + ? nil + : creation.deadline.formatted(.serverDateTime) + commentRequired = creation.requiresComment + ownerParticipates = creation.ownerParticipates + ownerDisplayName = creation.ownerDisplayName + } +} + +struct UpdateClassBody: Encodable, Sendable { + let name: String? + let deadline: String? +} + +struct ClassJoinPreviewQuery: Encodable, Sendable { + let inviteCode: String +} + +struct JoinClassBody: Encodable, Sendable { + let inviteCode: String + let displayName: String +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Ticket/ClassTicketService.swift b/Projects/Service/APIEndpoint/Sources/Class/Ticket/ClassTicketService.swift new file mode 100644 index 00000000..096fde6e --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Ticket/ClassTicketService.swift @@ -0,0 +1,37 @@ +import Alamofire +import API +import Foundation +import PickeNetwork + +public enum ClassTicketService { + case preview(code: String) + case register(code: String) + case me +} + +extension ClassTicketService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.classroom } + + public var path: String { + switch self { + case .preview: return ClassTicketAPI.preview.description + case .register: return ClassTicketAPI.register.description + case .me: return ClassTicketAPI.me.description + } + } + + public var method: HTTPMethod { + switch self { + case .preview, .me: return .get + case .register: return .post + } + } + + public var parameters: (any Encodable & Sendable)? { + switch self { + case let .preview(code): return ClassTicketCodeRequest(code: code) + case let .register(code): return ClassTicketCodeRequest(code: code) + case .me: return nil + } + } +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Ticket/Request/ClassTicketRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Ticket/Request/ClassTicketRequests.swift new file mode 100644 index 00000000..f6b6abbf --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Ticket/Request/ClassTicketRequests.swift @@ -0,0 +1,5 @@ +import Foundation + +struct ClassTicketCodeRequest: Encodable, Sendable { + let code: String +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/TopicDraft/ClassTopicDraftService.swift b/Projects/Service/APIEndpoint/Sources/Class/TopicDraft/ClassTopicDraftService.swift new file mode 100644 index 00000000..476eaace --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/TopicDraft/ClassTopicDraftService.swift @@ -0,0 +1,49 @@ +import Alamofire +import API +import ClassDomainInterface +import Foundation +import PickeNetwork + +public enum ClassTopicDraftService { + case create(ClassTopicFilter) + case detail(Int) + case suggest(Int, ClassTopicFilter?) + case select(draftID: Int, batchNo: Int, candidateID: Int) + case validate(draftID: Int, baseVersionID: Int, candidate: ClassTopicCandidate) +} + +extension ClassTopicDraftService: PickeDataRequest { + public var domain: any PickeDomainType { PieckeDomain.classroom } + + public var path: String { + switch self { + case .create: return ClassTopicDraftAPI.drafts.description + case let .detail(id): return ClassTopicDraftAPI.detail(draftID: id).description + case let .suggest(id, _): return ClassTopicDraftAPI.suggestions(draftID: id).description + case let .select(draftID, _, _): return ClassTopicDraftAPI.selection(draftID: draftID).description + case let .validate(draftID, _, _): return ClassTopicDraftAPI.validations(draftID: draftID).description + } + } + + public var method: HTTPMethod { + switch self { + case .detail: return .get + case .create, .suggest, .select, .validate: return .post + } + } + + public var parameters: (any Encodable & Sendable)? { + switch self { + case let .create(filter): + return TopicFilterBody(filter: filter) + case .detail: + return nil + case let .suggest(_, filter): + return filter.map(TopicFilterBody.init) + case let .select(_, batchNo, candidateID): + return TopicSelectionBody(batchNo: batchNo, candidateId: candidateID) + case let .validate(_, baseVersionID, candidate): + return TopicValidationBody(baseVersionId: baseVersionID, candidate: candidate) + } + } +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/TopicDraft/Request/ClassTopicDraftRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/TopicDraft/Request/ClassTopicDraftRequests.swift new file mode 100644 index 00000000..890c6938 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/TopicDraft/Request/ClassTopicDraftRequests.swift @@ -0,0 +1,56 @@ +import Foundation +import ClassDomainInterface + +struct TopicFilterBody: Encodable, Sendable { + let topicSeed: String + let difficulty: String + let categories: [String] + + init(filter: ClassTopicFilter) { + topicSeed = filter.keyword.trimmingCharacters(in: .whitespacesAndNewlines) + difficulty = filter.level.topicAPIValue + categories = filter.categories.reduce(into: [String]()) { titles, category in + if !titles.contains(category.title) { titles.append(category.title) } + } + } +} + +struct TopicSelectionBody: Encodable, Sendable { + let batchNo: Int + let candidateId: Int +} + +struct TopicValidationBody: Encodable, Sendable { + struct Option: Encodable, Sendable { + let label: String + let rationale: String + let philosopher: String? + } + + let baseVersionId: Int + let question: String + let description: String + let options: [Option] + + init( + baseVersionId: Int, + candidate: ClassTopicCandidate + ) { + self.baseVersionId = baseVersionId + question = candidate.question + description = candidate.description + options = candidate.options.map { + Option(label: $0.label, rationale: $0.rationale, philosopher: $0.philosopher) + } + } +} + +extension ClassAudienceLevel { + var topicAPIValue: String { + switch self { + case .middle: return "BEGINNER" + case .high: return "INTERMEDIATE" + case .adult: return "ADVANCED" + } + } +}