diff --git a/Projects/App/AuditHost/Sources/MainTabAuditView.swift b/Projects/App/AuditHost/Sources/MainTabAuditView.swift index a1383489..421009df 100644 --- a/Projects/App/AuditHost/Sources/MainTabAuditView.swift +++ b/Projects/App/AuditHost/Sources/MainTabAuditView.swift @@ -59,6 +59,7 @@ struct MainTabAuditView: View { if case var .home(home) = state.homeState.routes[0].screen { home.hasLoadedHome = true home.hasTriedAttendance = true + home.isUnreadBadgeLoading = false home.viewState = .loaded state.homeState.routes[screenAt: 0] = .home(home) } diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift index 33f0edbc..35584024 100644 --- a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift @@ -61,8 +61,8 @@ public struct ClassCoordinator { case let .members(members)?: return members.filter == nil && members.editName == nil && members.customAlert == nil && !members.isSaving - case .ticket?: - return true + case let .ticket(ticket)?: + return ticket.customAlert == nil default: return false } @@ -190,7 +190,8 @@ public struct ClassCoordinator { effect, cancelRemovedReportLoads(state: &state), cancelRemovedChatObservers(state: &state), - cancelRemovedClassEffects(state: &state) + cancelRemovedClassEffects(state: &state), + cancelRemovedMyOpinionsLoads(state: state) ) } } @@ -450,7 +451,12 @@ private extension ClassCoordinator { .cancel(id: ClassAIVoteFeature.CancelID.submit(id)), ] } - + removedTickets.map { .cancel(id: ClassTicketFeature.CancelID.check($0)) } + + removedTickets.flatMap { id in + [ + .cancel(id: ClassTicketFeature.CancelID.check(id)), + .cancel(id: ClassTicketFeature.CancelID.existingTicket(id)), + ] + } + removedBattleFlows.flatMap { id in [ .cancel(id: ClassBattleFlowFeature.CancelID.load(id)), @@ -462,6 +468,7 @@ private extension ClassCoordinator { .cancel(id: ClassBattleFlowFeature.CancelID.voteStats(id)), .cancel(id: ClassBattleFlowFeature.CancelID.perspectiveLike(id)), .cancel(id: ClassBattleFlowFeature.CancelID.myPerspective(id)), + .cancel(id: ClassBattleFlowFeature.CancelID.perspectiveMutation(id)), ] } guard !removedBattleFlows.isEmpty else { return .merge(effects) } @@ -481,6 +488,20 @@ private extension ClassCoordinator { return .merge(removedIDs.map { .cancel(id: ChatRoomFeature.CancelID.audioObserver($0)) }) } + /// 내 의견 화면은 항상 최상단 하나뿐이고 CancelID가 전역이므로, 라우트에 없으면 남은 조회를 모두 취소한다. + func cancelRemovedMyOpinionsLoads(state: State) -> Effect { + let hasMyOpinions = state.routes.contains { route in + guard case .myOpinions = route.screen else { return false } + return true + } + guard !hasMyOpinions else { return .none } + return .merge( + .cancel(id: ClassMyOpinionsFeature.CancelID.fetch), + .cancel(id: ClassMyOpinionsFeature.CancelID.replies), + .cancel(id: ClassMyOpinionsFeature.CancelID.moreReplies) + ) + } + func cancelRemovedReportLoads(state: inout State) -> Effect { let reportIDs = Set(state.routes.compactMap { route -> UUID? in guard case let .report(report) = route.screen else { return nil } @@ -500,7 +521,10 @@ private extension ClassCoordinator { cancelledReports.flatMap { id in [ .cancel(id: ClassReportFeature.CancelID.load(id)), + .cancel(id: ClassReportFeature.CancelID.voteHistory(id)), .cancel(id: ClassReportFeature.CancelID.generate(id)), + .cancel(id: ClassReportFeature.CancelID.insight(id)), + .cancel(id: ClassReportFeature.CancelID.myActivity(id)), ] } + cancelledDashboards.flatMap { id in @@ -508,6 +532,7 @@ private extension ClassCoordinator { .cancel(id: ClassOwnerDashboardFeature.CancelID.load(id)), .cancel(id: ClassOwnerDashboardFeature.CancelID.generate(id)), .cancel(id: ClassOwnerDashboardFeature.CancelID.generatePersonal(id)), + .cancel(id: ClassOwnerDashboardFeature.CancelID.myActivity(id)), ] } ) @@ -587,6 +612,30 @@ private extension ClassCoordinator { state.routes.push(.ticket(registration)) return .none + case let .routeAction(routeID, action: .ticket(.delegate(.existingTicketFound(ticket)))): + // 내가 등록한 사용 중 이용권을 다시 입력함: 바로 아래가 개요면 그리로 돌아가고, 아니면 이 화면을 개요로 바꾼다. + guard routeID == state.routes.count - 1, + case var .ticket(current)? = state.routes.last?.screen + else { return .none } + if routeID >= 1, + case var .ticket(previous) = state.routes[routeID - 1].screen, + previous.phase == .overview + { + state.routes.goBack() + previous.ticket = ticket + previous.registrationStartDate = ticket.activatedAt + previous.expirationDate = ticket.expiresAt + state.routes[screenAt: routeID - 1] = .ticket(previous) + return .none + } + current.preview = nil + current.ticket = ticket + current.registrationStartDate = ticket.activatedAt + current.expirationDate = ticket.expiresAt + current.phase = .overview + state.routes[screenAt: routeID] = .ticket(current) + return .none + case let .routeAction(routeID, action: .ticket(.delegate(.dismiss))): guard routeID == state.routes.count - 1, case .ticket = state.routes.last?.screen @@ -669,7 +718,11 @@ private extension ClassCoordinator { state.routes.push(.myClasses(.init())) return .none - case .routeAction(_, action: .intro(.delegate(.create))): + case let .routeAction(routeID, action: .intro(.delegate(.create))): + // 이용권 확인 응답이 늦게 와도 소개 화면이 맨 위일 때만 만들기 흐름을 연다. + guard routeID == 0, state.routes.count == 1, + case .intro = state.routes[0].screen + else { return .none } invalidateCompletingJoin(state: &state) state.routes.push(.startMethod(.init())) return .none diff --git a/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift index c2c59f53..e5174dbb 100644 --- a/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift +++ b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift @@ -73,7 +73,7 @@ public struct MainTabCoordinator { case .classroom: return !classState.showsTabBar case .myPage: - if case .ticket? = myPageState.routes.last?.screen { return false } + if case let .ticket(ticket)? = myPageState.routes.last?.screen { return ticket.customAlert != nil } return myPageState.routes.count > 1 case .none: return false @@ -186,19 +186,26 @@ public struct MainTabCoordinator { state.selectedTab = Tab.myPage.rawValue return .none + case .classroom(.router(.routeAction(0, action: .intro(.delegate(.registerTicket))))): + // 만료 안내의 등록하기: Android와 같이 마이 탭의 코드 입력 화면으로 바로 간다. + state.classState.routes.goBackToRoot() + state.myPageState.routes.goBackToRoot() + var registration = ClassTicketFeature.State() + registration.shouldCheckExistingTicket = false + state.myPageState.routes.push(.ticket(registration)) + if state.selectedTab != Tab.myPage.rawValue { + state.previousTab = state.selectedTab + trackTabSelection(Tab.myPage.rawValue) + } + state.selectedTab = Tab.myPage.rawValue + return .none + case .myPage(.router(.routeAction(_, action: .settings(.delegate(.sessionEnded))))): return .send(.delegate(.sessionEnded)) case .myPage(.router(.routeAction(_, action: .withdraw(.delegate(.sessionEnded))))): return .send(.delegate(.sessionEnded)) - case .home(.router(.routeAction(_, action: .home(.delegate(.moveToExplore))))): - if state.selectedTab != Tab.explore.rawValue { - state.previousTab = state.selectedTab - } - state.selectedTab = Tab.explore.rawValue - return .none - case .delegate: return .none @@ -233,7 +240,14 @@ private extension MainTabCoordinator { ) -> Effect { switch Tab(rawValue: tab) { case .home: - state.homeState.routes.goBackToRoot() + guard state.homeState.routes.count == 1 else { + state.homeState.routes.goBackToRoot() + return .none + } + return .send(.home(.router(.routeAction( + id: 0, + action: .home(.view(.homeTabReselected)) + )))) case .explore: state.exploreState.routes.goBackToRoot() case .quickBattle: diff --git a/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift b/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift index 9a89082d..7568bbdc 100644 --- a/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift +++ b/Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift @@ -75,7 +75,12 @@ private extension ProfileCoordinator { }) let removedIDs = state.ticketInstanceIDs.subtracting(currentIDs) state.ticketInstanceIDs = currentIDs - return .merge(removedIDs.map { .cancel(id: ClassTicketFeature.CancelID.check($0)) }) + return .merge(removedIDs.flatMap { id in + [ + .cancel(id: ClassTicketFeature.CancelID.check(id)), + .cancel(id: ClassTicketFeature.CancelID.existingTicket(id)), + ] + }) } func routerAction( @@ -215,6 +220,30 @@ private extension ProfileCoordinator { state.routes.push(.ticket(overview)) return .none + case let .routeAction(routeID, action: .ticket(.delegate(.existingTicketFound(ticket)))): + // 내가 등록한 사용 중 이용권을 다시 입력함: 바로 아래가 개요면 그리로 돌아가고, 아니면 이 화면을 개요로 바꾼다. + guard routeID == state.routes.count - 1, + case var .ticket(current)? = state.routes.last?.screen + else { return .none } + if routeID >= 1, + case var .ticket(previous) = state.routes[routeID - 1].screen, + previous.phase == .overview + { + state.routes.goBack() + previous.ticket = ticket + previous.registrationStartDate = ticket.activatedAt + previous.expirationDate = ticket.expiresAt + state.routes[screenAt: routeID - 1] = .ticket(previous) + return .none + } + current.preview = nil + current.ticket = ticket + current.registrationStartDate = ticket.activatedAt + current.expirationDate = ticket.expiresAt + current.phase = .overview + state.routes[screenAt: routeID] = .ticket(current) + return .none + case let .routeAction(routeID, action: .ticket(.delegate(.dismiss))): guard routeID == state.routes.count - 1, case .ticket = state.routes.last?.screen diff --git a/Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift b/Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift index 55b1fdaf..a2cb85e4 100644 --- a/Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift +++ b/Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift @@ -28,7 +28,11 @@ struct ClassCoordinatorFeedbackTests { removed.report = MockClassReport.figma removed.sourceViewState = .ready var state = ClassCoordinator.State() - state.routes.push(.detail(.init(room: room))) + state.routes.push(.detail(withDependencies { + $0.uuid = .incrementing + } operation: { + ClassDetailFeature.State(room: room) + })) state.routes.push(.report(first)) state.routes.push(.report(removed)) let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { @@ -69,7 +73,11 @@ struct ClassCoordinatorFeedbackTests { original.report = MockClassReport.figma original.sourceViewState = .ready var state = ClassCoordinator.State() - state.routes.push(.detail(.init(room: room))) + state.routes.push(.detail(withDependencies { + $0.uuid = .incrementing + } operation: { + ClassDetailFeature.State(room: room) + })) state.routes.push(.report(original)) let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { $0.classResultUseCase = generator @@ -116,7 +124,11 @@ struct ClassCoordinatorFeedbackTests { original.dashboard = MockClassOwnerDashboard.figma original.sourceViewState = .ready var state = ClassCoordinator.State() - state.routes.push(.detail(.init(room: room))) + state.routes.push(.detail(withDependencies { + $0.uuid = .incrementing + } operation: { + ClassDetailFeature.State(room: room) + })) state.routes.push(.ownerDashboard(original)) let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { $0.classResultUseCase = generator @@ -243,6 +255,13 @@ private actor SuspendedFeedbackGenerator: ClassResultInterface { throw ClassAIFeedbackError.generationFailed } + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + throw ClassAIFeedbackError.temporarilyUnavailable + } + private func suspend( request: @escaping @Sendable (CheckedContinuation) -> Request ) async throws -> T { diff --git a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift index dae174ab..b849ffe8 100644 --- a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift @@ -1364,6 +1364,22 @@ struct ClassCoordinatorTests { #expect(store.state.routes.count == 2) } + @Test + func 소개_화면이_맨_위가_아니면_늦게_온_만들기_요청을_무시한다() async { + var state = ClassCoordinator.State() + state.routes.push(.myClasses(withDependencies { + $0.uuid = .incrementing + } operation: { + MyClassFeature.State() + })) + let store = TestStore(initialState: state) { ClassCoordinator() } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 0, action: .intro(.delegate(.create))))) + + #expect(store.state.routes.count == 2) + } + @Test func 스와이프로_멤버_보고서_경로를_제거하면_진행중인_로드를_취소한다() async { let room = ClassRoom.mockClosedSociety @@ -1386,6 +1402,74 @@ struct ClassCoordinatorTests { #expect(store.state.routes.count == 2) } + @Test + func 스와이프로_내가_남긴_의견_경로를_제거하면_진행중인_조회를_취소한다() async { + let room = ClassRoom.mockClosedSociety + let useCase = SuspendedClassUseCase() + var opinions = ClassMyOpinionsFeature.State() + opinions.classID = room.id + var state = ClassCoordinator.State() + state.routes.push(.detail(testDetailState(room: room))) + state.routes.push(.myOpinions(opinions)) + let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { + $0.classResultUseCase = useCase + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .myOpinions(.async(.fetchReplyPage(cursor: nil)))))) + await useCase.waitForStarts(1) + await store.send(.router(.updateRoutes(Array(store.state.routes.dropLast())))) + await store.finish(timeout: .seconds(1)) + + #expect(await useCase.cancelledRequestIDs == [1]) + #expect(store.state.routes.count == 2) + } + + @Test + func 스와이프로_멤버_보고서_경로를_제거하면_진행중인_인사이트_조회를_취소한다() async { + let room = ClassRoom.mockClosedSociety + let useCase = SuspendedClassUseCase() + var state = ClassCoordinator.State() + state.routes.push(.detail(testDetailState(room: room))) + state.routes.push(.report(.init(room: room, instanceID: UUID()))) + let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { + $0.classResultUseCase = useCase + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .report(.async(.fetchInsight))))) + await useCase.waitForStarts(1) + await store.send(.router(.updateRoutes(Array(store.state.routes.dropLast())))) + await store.finish(timeout: .seconds(1)) + + #expect(await useCase.cancelledRequestIDs == [1]) + #expect(store.state.routes.count == 2) + } + + @Test + func 스와이프로_운영자_보고서_경로를_제거하면_진행중인_내_활동_조회를_취소한다() async { + let room = ClassRoom.mocks[0] + let useCase = SuspendedClassUseCase() + var state = ClassCoordinator.State() + state.routes.push(.detail(testDetailState(room: room))) + state.routes.push(.ownerDashboard(.init(room: room, instanceID: UUID()))) + let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { + $0.classResultUseCase = useCase + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .ownerDashboard(.async(.fetchMyActivity))))) + await useCase.waitForStarts(1) + await store.send(.router(.updateRoutes(Array(store.state.routes.dropLast())))) + await store.finish(timeout: .seconds(1)) + + #expect(await useCase.cancelledRequestIDs == [1]) + #expect(store.state.routes.count == 2) + } + @Test func 운영자_보고서가_루트로_제거되면_진행중인_로드를_취소한다() async { let room = ClassRoom.mocks[0] @@ -1669,6 +1753,16 @@ struct ClassParticipantPushTests { let secondEnd = try #require("2027.04.30".date(as: .yearMonthDayDotted)) let clock = TestClock() let originalID = UUID(400) + let preview = ClassTicketPreview(capacity: 30, registerBy: secondEnd, usageMonths: 6, registeredByMe: false) + let secondTicket = ClassTicket( + id: 5, + capacity: 30, + status: .active, + activatedAt: secondStart, + expiresAt: secondEnd, + remainingDays: 180, + readGraceEndsAt: secondEnd + ) var registration = ClassTicketFeature.State(instanceID: originalID) registration.phase = .registered registration.registrationStartDate = firstStart @@ -1680,6 +1774,7 @@ struct ClassParticipantPushTests { $0.uuid = .incrementing $0.continuousClock = clock $0.date = .constant(secondStart) + $0.classTicketUseCase = ClockTicketUseCase(clock: clock, preview: preview, ticket: secondTicket) } await store.send(.router(.routeAction( @@ -1700,7 +1795,9 @@ struct ClassParticipantPushTests { id: 2, action: .ticket(.delegate(.addRegistrationRequested)) ))) { - $0.routes.push(.ticket(.init(instanceID: UUID(1)))) + var registration = ClassTicketFeature.State(instanceID: UUID(1)) + registration.shouldCheckExistingTicket = false + $0.routes.push(.ticket(registration)) $0.ticketInstanceIDs = [originalID, UUID(0), UUID(1)] } await store.send(.router(.routeAction( @@ -1719,10 +1816,28 @@ struct ClassParticipantPushTests { ticket.isChecking = true $0.routes[screenAt: 3] = .ticket(ticket) } + // 현재 계약: 코드 확인은 미리보기만 보여 주고, 등록하기에서 등록한다. + await clock.advance(by: .milliseconds(350)) + await store.receive(\.router.routeAction) { + guard case var .ticket(ticket) = $0.routes[3].screen else { return } + ticket.isChecking = false + ticket.preview = preview + ticket.phase = .preview + $0.routes[screenAt: 3] = .ticket(ticket) + } + await store.send(.router(.routeAction( + id: 3, + action: .ticket(.view(.confirmTapped)) + ))) { + guard case var .ticket(ticket) = $0.routes[3].screen else { return } + ticket.isChecking = true + $0.routes[screenAt: 3] = .ticket(ticket) + } await clock.advance(by: .milliseconds(350)) await store.receive(\.router.routeAction) { guard case var .ticket(ticket) = $0.routes[3].screen else { return } ticket.isChecking = false + ticket.ticket = secondTicket ticket.phase = .registered ticket.registrationStartDate = secondStart ticket.expirationDate = secondEnd @@ -1734,8 +1849,10 @@ struct ClassParticipantPushTests { else { return } overview.registrationStartDate = secondStart overview.expirationDate = secondEnd + overview.ticket = secondTicket original.registrationStartDate = secondStart original.expirationDate = secondEnd + original.ticket = secondTicket $0.routes[screenAt: 2] = .ticket(overview) $0.routes[screenAt: 1] = .ticket(original) } @@ -1769,6 +1886,76 @@ struct ClassParticipantPushTests { #expect(original.periodText == "2026.11.01 – 2027.04.30") } + @Test + func 개요_위_등록_화면에서_사용_중인_내_이용권을_다시_입력하면_개요로_돌아간다() async { + let ticket = ClassTicket( + id: 9, + capacity: 30, + status: .active, + activatedAt: Date(timeIntervalSince1970: 1_800_000_000), + expiresAt: Date(timeIntervalSince1970: 1_815_000_000), + remainingDays: 170, + readGraceEndsAt: Date(timeIntervalSince1970: 1_817_592_000) + ) + var overview = ClassTicketFeature.State(instanceID: UUID(310)) + overview.phase = .overview + var registration = ClassTicketFeature.State(instanceID: UUID(311)) + registration.shouldCheckExistingTicket = false + var state = ClassCoordinator.State() + state.routes.push(.ticket(overview)) + state.routes.push(.ticket(registration)) + state.ticketInstanceIDs = [UUID(310), UUID(311)] + let store = TestStore(initialState: state) { ClassCoordinator() } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .ticket(.delegate(.existingTicketFound(ticket)))))) + + #expect(store.state.routes.count == 2) + guard case let .ticket(current)? = store.state.routes.last?.screen else { + Issue.record("기존 이용권 개요로 돌아가지 않았습니다") + return + } + #expect(current.instanceID == UUID(310)) + #expect(current.ticket == ticket) + } + + @Test + func 바로_연_이용권_화면에서_사용_중인_내_이용권을_다시_입력하면_개요로_바꾸고_안내_중에는_탭바를_숨긴다() async { + let ticket = ClassTicket( + id: 9, + capacity: 30, + status: .active, + activatedAt: Date(timeIntervalSince1970: 1_800_000_000), + expiresAt: Date(timeIntervalSince1970: 1_815_000_000), + remainingDays: 170, + readGraceEndsAt: Date(timeIntervalSince1970: 1_817_592_000) + ) + var registration = ClassTicketFeature.State(instanceID: UUID(312)) + registration.code = "PICKE30" + var state = ClassCoordinator.State() + state.routes.push(.ticket(registration)) + state.ticketInstanceIDs = [UUID(312)] + #expect(state.showsTabBar) + var alerted = registration + alerted.customAlert = .ticketInvalidCode() + var alertedState = state + alertedState.routes[screenAt: 1] = .ticket(alerted) + #expect(!alertedState.showsTabBar) + let store = TestStore(initialState: state) { ClassCoordinator() } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 1, action: .ticket(.delegate(.existingTicketFound(ticket)))))) + + #expect(store.state.routes.count == 2) + guard case let .ticket(current)? = store.state.routes.last?.screen else { + Issue.record("이용권 화면이 사라졌습니다") + return + } + #expect(current.instanceID == UUID(312)) + #expect(current.phase == .overview) + #expect(current.ticket == ticket) + } + @Test func 이용권_확인_중_스와이프로_경로가_제거되면_응답도_취소된다() async { let clock = TestClock() @@ -1781,6 +1968,7 @@ struct ClassParticipantPushTests { let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { $0.continuousClock = clock $0.date = .constant(Date(timeIntervalSince1970: 0)) + $0.classTicketUseCase = ClockTicketUseCase(clock: clock) } await store.send(.router(.routeAction(id: 1, action: .ticket(.view(.checkTapped))))) { @@ -1869,6 +2057,7 @@ struct ClassParticipantPushTests { let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { $0.continuousClock = clock $0.date = .constant(start) + $0.classTicketUseCase = ClockTicketUseCase(clock: clock) } await store.send(.router(.routeAction(id: 3, action: .ticket(.view(.checkTapped))))) { @@ -2762,6 +2951,33 @@ struct ChatPlayerLifecycleTests { } } +/// 코드 확인·등록이 테스트 시계 350ms 뒤에 끝나는 이용권 대역. +private struct ClockTicketUseCase: ClassTicketInterface { + let clock: TestClock + var preview = ClassTicketPreview( + capacity: 30, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: false + ) + var ticket: ClassTicket? + + func previewTicket(code _: String) async throws -> ClassTicketPreview { + try await clock.sleep(for: .milliseconds(350)) + return preview + } + + func registerTicket(code _: String) async throws -> ClassTicket { + try await clock.sleep(for: .milliseconds(350)) + guard let ticket else { throw ClassTicketError.invalidCode } + return ticket + } + + func fetchMyTicket() async throws -> ClassTicket { + throw ClassTicketError.notFound + } +} + private struct SlowBattleUseCase: ClassBattleInterface { let tracker: SlowBattleRequestTracker private let fallback = MockClassRepository() @@ -2777,6 +2993,14 @@ private struct SlowBattleUseCase: ClassBattleInterface { try await fallback.submitPreVote(classID: classID, optionID: optionID) } + func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + try await fallback.submitMidVote(classID: classID, checkpointID: checkpointID, optionID: optionID) + } + func completeTTS(classID _: Int) async throws { await tracker.markStarted() do { @@ -2999,11 +3223,20 @@ private struct FixedClassResultUseCase: ClassResultInterface { func retryInsight(roomID _: Int) async throws { throw ClassError.unknown("이 테스트는 인사이트 재시도를 사용하지 않습니다.") } + + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + throw ClassError.unknown("이 테스트는 내 활동 조회를 사용하지 않습니다.") + } } private actor SuspendedClassUseCase: ClassResultInterface { private var nextRequestID = 0 private var requests: [Int: CheckedContinuation] = [:] + private var activityRequests: [Int: CheckedContinuation] = [:] + private var insightRequests: [Int: CheckedContinuation] = [:] private var startedCount = 0 private var startWaiters: [(Int, CheckedContinuation)] = [] private(set) var cancelledRequestIDs: [Int] = [] @@ -3033,6 +3266,18 @@ private actor SuspendedClassUseCase: ClassResultInterface { } } + private func cancelInsight(_ id: Int) { + guard let continuation = insightRequests.removeValue(forKey: id) else { return } + cancelledRequestIDs.append(id) + continuation.resume(throwing: CancellationError()) + } + + private func cancelActivity(_ id: Int) { + guard let continuation = activityRequests.removeValue(forKey: id) else { return } + cancelledRequestIDs.append(id) + continuation.resume(throwing: CancellationError()) + } + private func cancelReport(_ id: Int) { guard let continuation = requests.removeValue(forKey: id) else { return } cancelledRequestIDs.append(id) @@ -3056,13 +3301,36 @@ private actor SuspendedClassUseCase: ClassResultInterface { } func fetchInsight(roomID _: Int) async throws -> ClassAIInsight { - throw ClassError.network("이 테스트는 인사이트 조회를 사용하지 않습니다.") + let id = nextID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + insightRequests[id] = continuation + didStart() + } + } onCancel: { + Task { await self.cancelInsight(id) } + } } func retryInsight(roomID _: Int) async throws { throw ClassError.network("이 테스트는 인사이트 재시도를 사용하지 않습니다.") } + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + let id = nextID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + activityRequests[id] = continuation + didStart() + } + } onCancel: { + Task { await self.cancelActivity(id) } + } + } + private static var fixture: ClassResult { ClassResult( classID: ClassRoom.mockClosedSociety.id, diff --git a/Projects/App/Tests/Sources/ClassFigmaParityCaptureTests.swift b/Projects/App/Tests/Sources/ClassFigmaParityCaptureTests.swift new file mode 100644 index 00000000..dc88c5d2 --- /dev/null +++ b/Projects/App/Tests/Sources/ClassFigmaParityCaptureTests.swift @@ -0,0 +1,258 @@ +// +// ClassFigmaParityCaptureTests.swift +// PickeTests +// +// Created by Picke on 11/2/26. +// + +import ClassDomainInterface +import ComposableArchitecture +import FeatureAssembly +@testable import Picke +import PickeDesignKit +import SwiftUI +import UIKit +import XCTest + +/// Run explicitly with PICKE_CLASS_VISUAL_AUDIT=1. Captures the real Class route +/// screens at 1x and the Figma frame size so they can be diffed against the +/// FigmaClassAudit plugin export. These images are evidence, not snapshot assertions. +final class ClassFigmaParityCaptureTests: XCTestCase { + @MainActor + func testCaptureClassFigmaParityFrames() async throws { + guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { + throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to capture Class Figma parity frames") + } + let directory = try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("ClassFigmaParity", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let phone = CGSize(width: 375, height: 812) + try await save(flowView(step: .none, selectedOptionID: 2), size: phone, name: "13337-56744-pre-vote", in: directory) + try await save( + flowView(step: .ttsListening, selectedOptionID: 2), + size: phone, + name: "13337-56966-tts", + in: directory + ) + try await save( + flowView(step: .postVote, selectedOptionID: 2), + size: phone, + name: "13337-57185-final-vote", + in: directory + ) + try await save( + flowView(step: .completed, selectedOptionID: 2), + size: phone, + name: "13384-57726-opinion", + in: directory + ) + try await save(reportView(), size: CGSize(width: 402, height: 1090), name: "13757-17445-result", in: directory) + } +} + +private extension ClassFigmaParityCaptureTests { + var room: ClassRoom { + let source = ClassRoom.mockClosedSociety + return ClassRoom( + id: source.id, + name: source.name, + joinCode: source.joinCode, + battle: source.battle, + deadline: source.deadline, + memberCount: source.memberCount, + role: .member, + status: .open, + allowsAnonymousOpinion: source.allowsAnonymousOpinion, + requiresComment: source.requiresComment, + ownerParticipates: source.ownerParticipates, + memberCapacity: source.memberCapacity + ) + } + + @MainActor + func flowView( + step: ClassBattleStep, + selectedOptionID: Int + ) -> AnyView { + var state = ClassBattleFlowFeature.State() + state.room = room + state.viewState = .loaded + // A pending load request keeps onAppear from replacing the seeded state with network calls. + state.loadRequestID = UUID() + state.selectedOptionID = selectedOptionID + state.session = ClassBattleSession( + classID: room.id, + myRole: .member, + closeAt: Date(timeIntervalSince1970: 1_790_154_000), + closed: false, + commentRequired: true, + canParticipate: true, + participationCompleted: false, + battle: ClassBattleDetail( + id: 10, + title: "촉법소년 연령을 낮춰야 할까?", + summary: "날로 잔혹해지는 청소년 범죄, 당신은 강력한 처벌을 원하십니까, 아니면 기회와 교정을 원하십니까?", + thumbnailURL: nil, + description: "날로 잔혹해지는 청소년 범죄, 당신은 강력한 처벌을 원하십니까, 아니면 기회와 교정을 원하십니까?", + shareURL: nil, + userVoteStatus: nil, + currentStep: step, + participantsCount: 28, + audioDuration: 268, + options: [ + .init(id: 1, label: "A", title: "예술이다", stance: "예술이다", representative: "사르트르", imageURL: nil), + .init(id: 2, label: "B", title: "변기는 변기다", stance: "변기는 변기다", representative: "플라톤", imageURL: nil), + ] + ), + midVoteEnabled: false + ) + state.hasPublishedPerspective = false + state.voteHistory = ClassVoteHistory( + battleTitle: "촉법소년 연령을 낮춰야 할까?", + preVote: .init(optionID: 1, label: "A", title: "예술이다"), + postVote: .init(optionID: 2, label: "B", title: "변기는 변기다"), + status: step, + opinionChanged: true + ) + state.isAudioReady = true + state.isPlaying = true + state.audioDuration = 268 + state.currentTime = 127 + state.transcript = [ + .init(id: 0, startTime: 0, speakerName: "한나 아렌트", speakerImageURL: nil, + text: "범죄의 잔혹함과 지능화에 나이는 더 이상 핑계가 될 수 없습니다!", side: "A"), + .init(id: 1, startTime: 10, speakerName: "한나 아렌트", speakerImageURL: nil, + text: "'촉법소년'이라는 제도가 오히려 아이들에게 법을 비웃고 범죄를 저지를 면죄부가 되고 있습니다.", side: "A"), + .init(id: 2, startTime: 20, speakerName: "한나 아렌트", speakerImageURL: nil, + text: "피해자의 눈물을 닦아주고 사법 정의를 세우려면 촉법 연령을 낮춰 단호하게 처벌해야 합니다.", side: "A"), + .init(id: 3, startTime: 40, speakerName: "루소", speakerImageURL: nil, + text: "아렌트 선생, 청소년은 아직 뇌와 도덕성이 성숙하지 못한 존재입니다!", side: "B"), + .init(id: 4, startTime: 60, speakerName: "루소", speakerImageURL: nil, + text: "성인과 똑같은 엄벌의 잣대를 대는 것은 사회가 아이들의 선도 가능성을 포기하는 것과 같습니다.", side: "B"), + .init(id: 5, startTime: 80, speakerName: "루소", speakerImageURL: nil, + text: "전과자라는 낙인을 찍어 감옥에 보내는 대신, 교육과 환경 개선을 통해 사회로 돌아오게 돕는 것이 국가의 의무입니다.", + side: "B"), + .init(id: 6, startTime: 120, speakerName: "한나 아렌트", speakerImageURL: nil, + text: "처벌받지 않는 악은 반드시 재범으로 이어집니다.", side: "A"), + ] + state.voteStats = ClassVoteStats( + options: [ + .init(id: 1, title: "예술이다", imageURL: nil, voteCount: 119, ratio: 59.5), + .init(id: 2, title: "변기는 변기다", imageURL: nil, voteCount: 81, ratio: 40.5), + ], + totalCount: 200, + updatedAt: .now + ) + let author = ClassPerspectiveAuthor( + userTag: "raccoon", + nickname: "사유하는 라쿤", + characterType: nil, + characterImageURL: nil + ) + let option = ClassPerspectiveOption(id: 2, label: "B", title: "변기는 변기다", stance: "변기는 변기다") + state.perspectives = (0 ..< 3).map { index in + ClassPerspectiveItem( + id: 100 + index, + author: author, + option: option, + content: "제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요. 네덜란드 사례를 보면 우려가 현실이 되고 있죠. 제도화가 무서운 건, 사회적 압력이 '선택'을 '의무'로 바꿀 수 있다는 거예요.", + likeCount: 1340, + commentCount: 23, + isLiked: false, + isMyPerspective: false, + status: nil, + createdAt: Date().addingTimeInterval(-120) + ) + } + let store = Store(initialState: state) { ClassBattleFlowFeature() } + return AnyView(ClassBattleFlowView(store: store)) + } + + @MainActor + func reportView() -> AnyView { + let source = ClassRoom.mockClosedSociety + let owner = ClassRoom( + id: source.id, + name: source.name, + joinCode: source.joinCode, + battle: source.battle, + deadline: source.deadline, + memberCount: source.memberCount, + role: .owner, + status: .closed, + allowsAnonymousOpinion: source.allowsAnonymousOpinion, + requiresComment: source.requiresComment, + ownerParticipates: source.ownerParticipates, + memberCapacity: source.memberCapacity + ) + var state = ClassReportFeature.State(room: owner, instanceID: UUID()) + state.sourceViewState = .ready + state.insight = ClassAIInsight( + text: "처음에는 뫼르소의 태도를 비난할 수 있다는 의견이 많았지만, 대화 후에는 행동과 감정을 분리해서 봐야 한다는 의견이 늘어났어요.", + highlights: [ + .init(content: "슬픔을 겉으로 드러내지 않는다고 해서 타인을 사랑하지 않는다고 판단할 수는 없다고 생각해요."), + .init(content: "감정은 개인의 자유지만, 사회적 관계에서는 최소한의 공감 표현도 필요하다고 생각해요."), + ] + ) + state.classResult = ClassResult( + classID: owner.id, + closed: true, + closeAt: owner.deadline, + stats: .init( + activeMemberCount: 32, + completedCount: 28, + participationRate: 87.5, + commentWriterCount: 24, + commentRate: 75, + postVoteCount: 28, + options: [ + .init(id: 1, title: "낮춰야한다", preVoteCount: 18, postVoteCount: 18, postVoteRatio: 64), + .init(id: 2, title: "교정이 우선이다", preVoteCount: 10, postVoteCount: 10, postVoteRatio: 36), + ] + ), + myReportState: nil, + insightState: "SUCCEEDED" + ) + let store = Store(initialState: state) { ClassReportFeature() } + return AnyView(ClassReportView(store: store)) + } + + @MainActor + func save( + _ view: AnyView, + size: CGSize, + name: String, + in directory: URL + ) async throws { + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + let host = UIHostingController(rootView: view.environment(\.colorScheme, .light)) + window.rootViewController = host + window.frame = CGRect(origin: .zero, size: size) + window.makeKeyAndVisible() + // A window shorter than the screen sits above the home indicator, so mirror a device bottom safe area. + host.additionalSafeAreaInsets.bottom = max(0, 34 - window.safeAreaInsets.bottom) + defer { window.isHidden = true } + host.view.frame = window.bounds + host.view.layoutIfNeeded() + // Report charts animate after data arrives. + try await Task.sleep(for: .milliseconds(1200)) + host.view.layoutIfNeeded() + CATransaction.flush() + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try XCTUnwrap(image.pngData()).write(to: directory.appendingPathComponent(name + ".png")) + let attachment = XCTAttachment(image: image) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift b/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift index f05f37c6..7da61cf2 100644 --- a/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift +++ b/Projects/App/Tests/Sources/ClassFlowVisualAuditTests.swift @@ -435,6 +435,68 @@ final class ClassFlowVisualAuditTests: XCTestCase { } } + @MainActor + func testCaptureServerResultParticipationAndStance() async throws { + guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { + throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to capture the server result") + } + let source = ClassRoom.mockClosedSociety + let room = ClassRoom( + id: source.id, + name: source.name, + joinCode: source.joinCode, + battle: source.battle, + deadline: source.deadline, + memberCount: source.memberCount, + role: .owner, + status: .closed, + allowsAnonymousOpinion: source.allowsAnonymousOpinion, + requiresComment: source.requiresComment, + ownerParticipates: source.ownerParticipates, + memberCapacity: source.memberCapacity + ) + var state = ClassReportFeature.State(room: room, instanceID: UUID()) + state.sourceViewState = .ready + state.insight = ClassAIInsight(text: "처음에는 민로스의 태도를 비난할 수 있다는 의견이 많았지만, 대화 후에는 형평과 감정을 분리해 봐야 한다는 의견이 늘어났어요.") + state.classResult = ClassResult( + classID: room.id, + closed: true, + closeAt: room.deadline, + stats: .init( + activeMemberCount: 32, + completedCount: 28, + participationRate: 87.5, + commentWriterCount: 24, + commentRate: 75, + postVoteCount: 28, + options: [ + .init(id: 1, title: "낮춰야한다", preVoteCount: 18, postVoteCount: 18, postVoteRatio: 64), + .init(id: 2, title: "교정이 우선이다", preVoteCount: 10, postVoteCount: 10, postVoteRatio: 36) + ] + ), + myReportState: nil, + insightState: "SUCCEEDED" + ) + let store = Store(initialState: state) { ClassReportFeature() } + let (top, bottom) = try await capture( + AnyView(ClassReportView(store: store)), + ready: nil, + includeScrollBottom: true, + viewport: CGSize(width: 402, height: 874) + ) + for (name, image) in [("top", top), ("bottom", try XCTUnwrap(bottom))] { + let attachment = XCTAttachment(image: image) + attachment.name = "class-server-result-402-\(name)" + attachment.lifetime = .keepAlways + add(attachment) + } + let topText = try recognizedAuditText(in: top, name: "class-server-result-402-top") + let bottomText = try recognizedAuditText(in: XCTUnwrap(bottom), name: "class-server-result-402-bottom") + XCTAssertTrue(topText.contains("클래스참여현황"), "Participation card missing: \(topText)") + XCTAssertTrue(topText.contains("참여한멤버"), "Participation caption missing: \(topText)") + XCTAssertTrue(bottomText.contains("입장분포"), "Stance card missing: \(bottomText)") + } + @MainActor func testClassReportLayoutFooters() async throws { guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { @@ -3091,6 +3153,13 @@ final class ClassFlowVisualAuditTests: XCTestCase { } func retryInsight(roomID _: Int) async throws {} + + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + throw ClassAIFeedbackError.temporarilyUnavailable + } } private static func visualResultUseCase() -> VisualResultUseCase { VisualResultUseCase() } @@ -3302,12 +3371,11 @@ final class ClassFlowVisualAuditTests: XCTestCase { home.heroes = homeBundle.heroes home.hotBattles = homeBundle.hotBattles home.bestBattles = homeBundle.bestBattles - home.quizzes = homeBundle.quizzes - home.votes = homeBundle.votes home.newBattles = homeBundle.newBattles home.viewState = .loaded home.hasLoadedHome = true home.hasTriedAttendance = true + home.isUnreadBadgeLoading = false home.attendanceModal = nil homeState.homeState.routes[screenAt: 0] = .home(home) let expectedHome = home diff --git a/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift b/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift index 5b166307..7b6640fd 100644 --- a/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift +++ b/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift @@ -15,7 +15,9 @@ final class ClassTicketVisualAuditTests: XCTestCase { throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to capture ticket screens") } - let entry = ClassTicketFeature.State() + var entry = ClassTicketFeature.State() + // 진입 조회가 실패하면 토스트 후 닫히므로 입력 화면 캡처에서는 조회하지 않는다. + entry.shouldCheckExistingTicket = false var registered = ClassTicketFeature.State() registered.phase = .registered diff --git a/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift b/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift index 2560e66b..f1517030 100644 --- a/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/MainTabCoordinatorTests.swift @@ -52,6 +52,45 @@ struct MainTabCoordinatorTests { await store.finish() } + @Test + func 클래스_만료_안내의_등록하기는_마이_탭_코드_입력_화면을_연다() async { + var state = MainTabCoordinator.State() + state.classState.routes.push(.myClasses(withDependencies { + $0.uuid = .incrementing + } operation: { + MyClassFeature.State() + })) + let store = TestStore(initialState: state) { + MainTabCoordinator() + } withDependencies: { + $0.analyticsUseCase.track = { _ in } + } + store.exhaustivity = .off + + await store.send(.classroom(.router(.routeAction(id: 0, action: .intro(.delegate(.registerTicket)))))) + + #expect(store.state.selectedTab == MainTabCoordinator.Tab.myPage.rawValue) + #expect(store.state.classState.routes.count == 1) + guard case let .ticket(ticket)? = store.state.myPageState.routes.last?.screen else { + Issue.record("마이 탭에 이용권 화면이 열리지 않았습니다") + return + } + #expect(ticket.shouldCheckExistingTicket == false) + #expect(ticket.phase == .entry) + } + + @Test + func 마이_탭_이용권_화면은_안내가_떠_있을_때만_탭바를_숨긴다() { + var state = MainTabCoordinator.State(selectedTab: MainTabCoordinator.Tab.myPage.rawValue) + var ticket = ClassTicketFeature.State() + state.myPageState.routes.push(.ticket(ticket)) + #expect(!state.shouldHideTabBar) + + ticket.customAlert = .ticketInvalidCode() + state.myPageState.routes[screenAt: 1] = .ticket(ticket) + #expect(state.shouldHideTabBar) + } + @Test func 탭_변경은_한번만_기록하고_재선택과_잘못된_탭은_기록하지_않는다() async { let events = LockIsolated<[AnalyticsButton]>([]) @@ -77,6 +116,57 @@ struct MainTabCoordinatorTests { #expect(events.value == [.tabExplore]) } + @Test + func 홈_루트에서_홈_재선택은_맨_위로만_올리고_다시_부르지_않는다() async { + var state = MainTabCoordinator.State() + state.homeState = .init() + let store = TestStore(initialState: state) { + MainTabCoordinator() + } withDependencies: { + $0.analyticsUseCase.track = { _ in } + } + store.exhaustivity = .off + + await store.send(.tabReselected(MainTabCoordinator.Tab.home.rawValue)) + await store.receive(\.home.router.routeAction) + + guard case let .home(homeFeature) = store.state.homeState.routes[0].screen else { + Issue.record("홈 루트 화면이 HomeFeature 가 아님") + return + } + #expect(homeFeature.scrollToTopRequest == 1) + + // Android 처럼 다시 부르지 않는다 — 이어지는 액션이 없고 처음 상태(미로드)가 그대로 남는다. + await store.finish() + guard case let .home(afterReselect) = store.state.homeState.routes[0].screen else { + Issue.record("홈 루트 화면이 HomeFeature 가 아님") + return + } + #expect(afterReselect.hasLoadedHome == false) + #expect(afterReselect.viewState == .loaded) + } + + @Test + func 홈_하위_화면에서_홈_재선택은_루트로만_돌아간다() async { + var state = MainTabCoordinator.State() + state.homeState.routes.push(.home(HomeFeature.State())) + let store = TestStore(initialState: state) { + MainTabCoordinator() + } withDependencies: { + $0.analyticsUseCase.track = { _ in } + } + store.exhaustivity = .off + + await store.send(.tabReselected(MainTabCoordinator.Tab.home.rawValue)) + + #expect(store.state.homeState.routes.count == 1) + guard case let .home(homeFeature) = store.state.homeState.routes[0].screen else { + Issue.record("홈 루트 화면이 HomeFeature 가 아님") + return + } + #expect(homeFeature.scrollToTopRequest == 0) + } + @Test func 배틀_복귀_후_클래스를_재선택해도_소개에서_홈으로_나갈_수_있다() async { var state = MainTabCoordinator.State(selectedTab: MainTabCoordinator.Tab.quickBattle.rawValue) diff --git a/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift b/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift index 3c280982..405854a3 100644 --- a/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift @@ -1,3 +1,4 @@ +import ClassDomainInterface import ComposableArchitecture import FeatureAssembly import Foundation @@ -115,4 +116,103 @@ struct ProfileTicketCoordinatorTests { #expect(updated.expirationDate == laterEnd) await store.finish() } + + @Test + func 개요_위에서_사용_중인_내_이용권을_다시_입력하면_기존_개요로_돌아간다() async { + let ticket = profileActiveTicket() + var overview = ClassTicketFeature.State(instanceID: UUID(10)) + overview.phase = .overview + var registration = ClassTicketFeature.State(instanceID: UUID(11)) + registration.shouldCheckExistingTicket = false + var state = Picke.ProfileCoordinator.State() + state.routes.push(.ticket(overview)) + state.routes.push(.ticket(registration)) + let store = TestStore(initialState: state) { Picke.ProfileCoordinator() } withDependencies: { + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 2, action: .ticket(.delegate(.existingTicketFound(ticket)))))) + + #expect(store.state.routes.count == 2) + guard case let .ticket(current)? = store.state.routes.last?.screen else { + Issue.record("기존 이용권 개요로 돌아가지 않았습니다") + return + } + #expect(current.instanceID == UUID(10)) + #expect(current.phase == .overview) + #expect(current.ticket == ticket) + } + + @Test + func 바로_연_등록_화면에서_사용_중인_내_이용권을_다시_입력하면_그_화면을_개요로_바꾼다() async { + let ticket = profileActiveTicket() + var registration = ClassTicketFeature.State(instanceID: UUID(12)) + registration.code = "PICKE30" + var state = Picke.ProfileCoordinator.State() + state.routes.push(.ticket(registration)) + let store = TestStore(initialState: state) { Picke.ProfileCoordinator() } withDependencies: { + $0.uuid = .incrementing + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 1, action: .ticket(.delegate(.existingTicketFound(ticket)))))) + + #expect(store.state.routes.count == 2) + guard case let .ticket(current)? = store.state.routes.last?.screen else { + Issue.record("이용권 화면이 사라졌습니다") + return + } + #expect(current.instanceID == UUID(12)) + #expect(current.phase == .overview) + #expect(current.ticket == ticket) + #expect(current.preview == nil) + } + + @Test + func 이용권_화면을_닫으면_진입_조회도_취소한다() async { + let ticketID = UUID(13) + var state = Picke.ProfileCoordinator.State() + state.routes.push(.ticket(ClassTicketFeature.State(instanceID: ticketID))) + state.ticketInstanceIDs = [ticketID] + let store = TestStore(initialState: state) { Picke.ProfileCoordinator() } withDependencies: { + $0.uuid = .incrementing + $0.classTicketUseCase = NeverEndingTicketUseCase() + } + store.exhaustivity = .off + + await store.send(.router(.routeAction(id: 1, action: .ticket(.view(.onAppear))))) + await store.send(.router(.updateRoutes(Array(store.state.routes.dropLast())))) + await store.finish(timeout: .seconds(1)) + + #expect(store.state.routes.count == 1) + } +} + +private func profileActiveTicket() -> ClassTicket { + ClassTicket( + id: 7, + capacity: 30, + status: .active, + activatedAt: Date(timeIntervalSince1970: 1_800_000_000), + expiresAt: Date(timeIntervalSince1970: 1_815_000_000), + remainingDays: 170, + readGraceEndsAt: Date(timeIntervalSince1970: 1_817_592_000) + ) +} + +/// 내 이용권 조회가 취소될 때까지 끝나지 않는 대역. +private struct NeverEndingTicketUseCase: ClassTicketInterface { + func previewTicket(code _: String) async throws -> ClassTicketPreview { + throw ClassTicketError.invalidCode + } + + func registerTicket(code _: String) async throws -> ClassTicket { + throw ClassTicketError.invalidCode + } + + func fetchMyTicket() async throws -> ClassTicket { + try await Task.sleep(for: .seconds(60)) + throw ClassTicketError.notFound + } } diff --git a/Projects/App/UITests/Sources/CommentInteractionTests.swift b/Projects/App/UITests/Sources/CommentInteractionTests.swift index b0491649..bb39c4be 100644 --- a/Projects/App/UITests/Sources/CommentInteractionTests.swift +++ b/Projects/App/UITests/Sources/CommentInteractionTests.swift @@ -41,7 +41,7 @@ private extension CommentInteractionTests { if scenario == "class" { XCTAssertTrue(app.buttons["리포트"].exists, "Class comment root must show its report action") } - let placeholder = scenario == "class" ? "내 생각을 입력해보세요" : "내용을 입력하세요" + let placeholder = scenario == "class" ? "내 생각을 입력해보세요" : "의견을 남겨보세요..." let emptyComposer = try composer(in: app, placeholder: placeholder, empty: true) let emptySend = try sendButton(in: app, scenario: scenario, composer: emptyComposer) XCTAssertFalse(emptySend.isEnabled, "Empty send must be disabled") diff --git a/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift b/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift index eb253a88..1a52d3d4 100644 --- a/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift +++ b/Projects/Core/PickeNetwork/Sources/Monitor/PickeEventMonitor.swift @@ -29,10 +29,14 @@ struct PickeEventMonitor: EventMonitor { ) { let urlRequest = response.request let method = urlRequest?.httpMethod ?? "?" - let url = urlRequest?.url?.absoluteString ?? "?" + // Query values can contain invite or ticket codes; keep only the route in logs. + let url = urlRequest?.url?.path ?? "?" let status = response.response?.statusCode ?? -1 let milliseconds = Int((response.metrics?.taskInterval.duration ?? 0) * 1000) let isFailure = response.error != nil + let isSensitiveRoute = url.hasPrefix("/api/v1/auth/") || + url.hasPrefix("/api/v1/classes") || + url.hasPrefix("/api/v1/class-tickets") var lines = ["\(isFailure ? "❌" : "✅") \(method) \(url) → \(status) (\(milliseconds)ms)"] @@ -41,7 +45,7 @@ struct PickeEventMonitor: EventMonitor { lines.append(" · Request Headers\n\(format(headers))") } if let body = urlRequest?.httpBody, !body.isEmpty { - lines.append(" · Request Body\n\(indent(prettyBody(body)))") + lines.append(" · Request Body\n\(isSensitiveRoute ? " " : indent(prettyBody(body)))") } // ── 응답 ── @@ -58,7 +62,7 @@ struct PickeEventMonitor: EventMonitor { } } if let data = response.data, !data.isEmpty { - lines.append(" · Response Body\n\(indent(prettyBody(data)))") + lines.append(" · Response Body\n\(isSensitiveRoute ? " " : indent(prettyBody(data)))") } let message = lines.joined(separator: "\n") @@ -118,7 +122,11 @@ private extension PickeEventMonitor { func format(_ headers: [String: String]) -> String { headers .sorted { $0.key < $1.key } - .map { " \($0.key): \($0.value)" } + .map { key, value in + let shouldRedact = ["authorization", "cookie", "set-cookie"] + .contains(key.lowercased()) || key.lowercased().contains("token") + return " \(key): \(shouldRedact ? "" : value)" + } .joined(separator: "\n") } diff --git a/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattle.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattle.swift index e3aa1f13..67e68bd8 100644 --- a/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattle.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/Recommend/RecommendedBattle.swift @@ -39,3 +39,14 @@ public struct RecommendedBattle: Equatable, Identifiable, Hashable { self.options = options } } + +public extension RecommendedBattle { + /// Android `RecommendItemCard` 태그 칩 — 첫 태그, 없으면 "#이슈". + var tagLabel: String { "#\(tags.first?.name ?? "이슈")" } + + /// Android `RecommendUiModel.audioDuration` — 1~59초는 1분, 그 밖은 초 ÷ 60 내림. + var durationLabel: String { + let minutes = (1 ..< 60).contains(audioDuration) ? 1 : audioDuration / 60 + return "\(minutes)분" + } +} diff --git a/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/VoteOptionSummary.swift b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/VoteOptionSummary.swift index 01558811..b3042bd0 100644 --- a/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/VoteOptionSummary.swift +++ b/Projects/Domain/BattleDomain/Interface/Sources/Entity/Battle/VoteStats/VoteOptionSummary.swift @@ -31,3 +31,11 @@ public struct VoteOptionSummary: Equatable { self.percentage = percentage } } + +public extension VoteOptionSummary { + /// Android `formatRate` — 소수 한 자리, `.0`이면 뗀다("59%", "59.5%"). + var rateLabel: String { + let text = String(format: "%.1f", percentage * 100) + return (text.hasSuffix(".0") ? String(text.dropLast(2)) : text) + "%" + } +} diff --git a/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift index 09f251cc..478ab7ac 100644 --- a/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/DTO/BattleScenarioDataDTO.swift @@ -78,6 +78,8 @@ public struct ScenarioScriptDTO: Decodable { } public struct ScenarioInteractiveOptionDTO: Decodable { - public let label: String - public let nextNodeId: Int + public let optionId: Int? + public let label: String? + public let title: String? + public let nextNodeId: Int? } diff --git a/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift index 07840d0c..da96e80b 100644 --- a/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift +++ b/Projects/Domain/BattleDomain/Sources/Model/Mapper/BattleScenarioDataDTO+.swift @@ -50,7 +50,7 @@ public extension ScenarioNodeDTO { audioDuration: audioDuration, autoNextNodeId: autoNextNodeId, scripts: scripts.map { $0.toDomain() }, - interactiveOptions: (interactiveOptions ?? []).map { $0.toDomain() } + interactiveOptions: (interactiveOptions ?? []).compactMap { $0.toDomain() } ) } } @@ -68,7 +68,16 @@ public extension ScenarioScriptDTO { } public extension ScenarioInteractiveOptionDTO { - func toDomain() -> ScenarioInteractiveOption { - ScenarioInteractiveOption(label: label, nextNodeId: nextNodeId) + func toDomain() -> ScenarioInteractiveOption? { + guard let nextNodeId else { return nil } + let displayLabel = label?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedLabel = (displayLabel?.isEmpty == false ? displayLabel : nil) ?? displayTitle ?? "선택지" + return ScenarioInteractiveOption( + label: resolvedLabel, + nextNodeId: nextNodeId, + optionID: optionId, + title: displayTitle + ) } } diff --git a/Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift b/Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift index 7323e98f..4fac9844 100644 --- a/Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift +++ b/Projects/Domain/BattleDomain/Tests/Sources/BattleDomainTests.swift @@ -6,6 +6,7 @@ // @testable import BattleDomain +import Foundation import Testing struct BattleDomainTests { @@ -13,4 +14,25 @@ struct BattleDomainTests { func battleDomainExample() { #expect(true) } + + @Test + func interactiveOptionPreservesServerID() throws { + let json = Data(#"{"optionId":9001,"label":"유죄","nextNodeId":802}"#.utf8) + let option = try #require(JSONDecoder().decode(ScenarioInteractiveOptionDTO.self, from: json).toDomain()) + + #expect(option.optionID == 9001) + #expect(option.label == "유죄") + #expect(option.nextNodeId == 802) + } + + @Test + func interactiveOptionFallsBackToTitleAndSkipsMissingNextNode() throws { + let titleOnly = Data(#"{"optionId":9001,"title":"피고인의 사정을 듣는다","nextNodeId":802}"#.utf8) + let option = try #require(JSONDecoder().decode(ScenarioInteractiveOptionDTO.self, from: titleOnly).toDomain()) + #expect(option.label == "피고인의 사정을 듣는다") + #expect(option.title == "피고인의 사정을 듣는다") + + let missingNext = Data(#"{"optionId":9002,"label":"유죄","title":"유죄를 선택한다"}"#.utf8) + #expect(try JSONDecoder().decode(ScenarioInteractiveOptionDTO.self, from: missingNext).toDomain() == nil) + } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift index ec3b1e2e..dbf211d4 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/AIFeedback/ClassAIPersonalFeedback.swift @@ -15,6 +15,7 @@ public struct ClassAIPersonalFeedback: Equatable, Sendable { public let coverage: String? public let excludedCount: Int public let preOption: ClassAIFeedbackVoteChoice? + public let midVotes: [ClassMidVoteChoice] public let postOption: ClassAIFeedbackVoteChoice? public let perspectiveCount: Int public let commentCount: Int @@ -37,6 +38,7 @@ public struct ClassAIPersonalFeedback: Equatable, Sendable { coverage: String? = nil, excludedCount: Int = 0, preOption: ClassAIFeedbackVoteChoice? = nil, + midVotes: [ClassMidVoteChoice] = [], postOption: ClassAIFeedbackVoteChoice? = nil, perspectiveCount: Int = 0, commentCount: Int = 0 @@ -50,6 +52,7 @@ public struct ClassAIPersonalFeedback: Equatable, Sendable { self.coverage = coverage self.excludedCount = excludedCount self.preOption = preOption + self.midVotes = midVotes self.postOption = postOption self.perspectiveCount = perspectiveCount self.commentCount = commentCount diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift index d8d7951f..255b82dd 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Battle/ClassBattleSession.swift @@ -9,6 +9,7 @@ public struct ClassBattleSession: Equatable, Sendable { public let canParticipate: Bool public let participationCompleted: Bool public let battle: ClassBattleDetail + public let midVoteEnabled: Bool public init( classID: Int, @@ -18,7 +19,8 @@ public struct ClassBattleSession: Equatable, Sendable { commentRequired: Bool, canParticipate: Bool, participationCompleted: Bool, - battle: ClassBattleDetail + battle: ClassBattleDetail, + midVoteEnabled: Bool = false ) { self.classID = classID self.myRole = myRole @@ -28,5 +30,6 @@ public struct ClassBattleSession: Equatable, Sendable { self.canParticipate = canParticipate self.participationCompleted = participationCompleted self.battle = battle + self.midVoteEnabled = midVoteEnabled } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift index 19730900..df3cbeb8 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassBattleError.swift @@ -8,6 +8,11 @@ public enum ClassBattleError: LocalizedError, Equatable, Sendable { case preVoteLocked case invalidOption case ttsUnavailable + case ttsIncomplete + case midVoteUnavailable + case invalidMidVoteOption + case midVoteLocked + case midVoteRequired case voteNotFound case preVoteRequired case alreadySubmitted @@ -26,6 +31,11 @@ public enum ClassBattleError: LocalizedError, Equatable, Sendable { case .preVoteLocked: return "청취 또는 토론을 시작한 뒤에는 사전투표를 바꿀 수 없어요." case .invalidOption: return "이 배틀의 선택지가 아니에요." case .ttsUnavailable: return "이 클래스에서는 TTS 청취를 사용할 수 없어요." + case .ttsIncomplete: return "TTS 청취를 완료한 뒤 투표해 주세요." + case .midVoteUnavailable: return "이 배틀에서는 중간투표를 사용할 수 없어요." + case .invalidMidVoteOption: return "이 선택 지점의 선택지가 아니에요." + case .midVoteLocked: return "사후투표를 마친 뒤에는 중간투표를 바꿀 수 없어요." + case .midVoteRequired: return "선택 지점의 투표를 완료해 주세요." case .voteNotFound: return "투표 내역을 찾을 수 없어요." case .preVoteRequired: return "먼저 사전투표를 완료해 주세요." case .alreadySubmitted: return "사후투표는 다시 제출할 수 없어요." diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift index 1fcd2ac1..752081ab 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Error/ClassError.swift @@ -30,7 +30,9 @@ public enum ClassError: LocalizedError, Equatable, Sendable { case topicVersionNotFound case topicDraftUsed case inviteCodeFailed + case requestKeyConflict case network(String) + case userMessage(String) case unknown(String) public var errorDescription: String? { @@ -61,7 +63,10 @@ public enum ClassError: LocalizedError, Equatable, Sendable { case .topicVersionNotFound: return "존재하지 않는 주제 버전입니다." case .topicDraftUsed: return "이 주제 초안으로 이미 만든 클래스가 있습니다." case .inviteCodeFailed: return "초대코드 생성에 실패했습니다. 다시 시도해 주세요." - case let .network(message), let .unknown(message): return message + case .requestKeyConflict: return "클래스 생성 요청이 충돌했어요. 입력 내용을 확인하고 다시 시도해 주세요." + case let .network(message): return message + case let .userMessage(message): return message + case .unknown: return "클래스 요청을 처리하지 못했습니다. 다시 시도해 주세요." } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivity.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivity.swift new file mode 100644 index 00000000..7256b4e9 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivity.swift @@ -0,0 +1,27 @@ +import Foundation + +/// 클래스 결과의 내 활동. 받은 추천 수와 내가 남긴 댓글을 커서 단위로 받는다. +public struct ClassMyActivity: Equatable, Sendable { + public let classID: Int + public let receivedLikeCount: Int + public let commentCount: Int + public let comments: [ClassMyActivityComment] + public let nextCursor: String? + public let hasNext: Bool + + public init( + classID: Int, + receivedLikeCount: Int, + commentCount: Int, + comments: [ClassMyActivityComment], + nextCursor: String?, + hasNext: Bool + ) { + self.classID = classID + self.receivedLikeCount = receivedLikeCount + self.commentCount = commentCount + self.comments = comments + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivityComment.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivityComment.swift new file mode 100644 index 00000000..ee4273df --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassMyActivityComment.swift @@ -0,0 +1,30 @@ +import Foundation + +/// 내가 다른 의견에 남긴 댓글 한 건. +public struct ClassMyActivityComment: Equatable, Sendable, Identifiable { + public let id: Int + public let perspectiveID: Int + public let perspectiveTitle: String + public let stance: String + public let content: String + public let likeCount: Int + public let createdAt: Date? + + public init( + id: Int, + perspectiveID: Int, + perspectiveTitle: String, + stance: String, + content: String, + likeCount: Int, + createdAt: Date? + ) { + self.id = id + self.perspectiveID = perspectiveID + self.perspectiveTitle = perspectiveTitle + self.stance = stance + self.content = content + self.likeCount = likeCount + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteCheckpoint.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteCheckpoint.swift new file mode 100644 index 00000000..9d0bbefe --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteCheckpoint.swift @@ -0,0 +1,15 @@ +public struct ClassResultMidVoteCheckpoint: Equatable, Identifiable, Sendable { + public let id: Int + public let voteCount: Int + public let options: [ClassResultMidVoteOption] + + public init( + id: Int, + voteCount: Int, + options: [ClassResultMidVoteOption] + ) { + self.id = id + self.voteCount = voteCount + self.options = options + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteOption.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteOption.swift new file mode 100644 index 00000000..c569823b --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultMidVoteOption.swift @@ -0,0 +1,18 @@ +public struct ClassResultMidVoteOption: Equatable, Identifiable, Sendable { + public let id: Int + public let label: String + public let voteCount: Int + public let ratio: Double + + public init( + id: Int, + label: String, + voteCount: Int, + ratio: Double + ) { + self.id = id + self.label = label + self.voteCount = voteCount + self.ratio = ratio + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift index c21bb17a..d6391f51 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Result/ClassResultStats.swift @@ -5,6 +5,9 @@ public struct ClassResultStats: Equatable, Sendable { public let commentWriterCount: Int public let commentRate: Double public let postVoteCount: Int + public let midVoteEnabled: Bool + public let midVoteCount: Int + public let midVoteCheckpoints: [ClassResultMidVoteCheckpoint] public let options: [ClassResultOption] public init( @@ -14,6 +17,9 @@ public struct ClassResultStats: Equatable, Sendable { commentWriterCount: Int, commentRate: Double, postVoteCount: Int, + midVoteEnabled: Bool = false, + midVoteCount: Int = 0, + midVoteCheckpoints: [ClassResultMidVoteCheckpoint] = [], options: [ClassResultOption] ) { self.activeMemberCount = activeMemberCount @@ -22,6 +28,9 @@ public struct ClassResultStats: Equatable, Sendable { self.commentWriterCount = commentWriterCount self.commentRate = commentRate self.postVoteCount = postVoteCount + self.midVoteEnabled = midVoteEnabled + self.midVoteCount = midVoteCount + self.midVoteCheckpoints = midVoteCheckpoints self.options = options } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift index c026d7e0..4932f71e 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Room/ClassCreation.swift @@ -16,6 +16,7 @@ public struct ClassCreation: Equatable, Sendable { public var requiresComment: Bool public var ownerParticipates: Bool public var ownerDisplayName: String? + public var requestKey: String? public init( name: String, @@ -24,7 +25,8 @@ public struct ClassCreation: Equatable, Sendable { allowsAnonymousOpinion: Bool, requiresComment: Bool, ownerParticipates: Bool = false, - ownerDisplayName: String? = nil + ownerDisplayName: String? = nil, + requestKey: String? = nil ) { self.name = name self.deadline = deadline @@ -34,6 +36,7 @@ public struct ClassCreation: Equatable, Sendable { self.requiresComment = requiresComment self.ownerParticipates = ownerParticipates self.ownerDisplayName = ownerDisplayName + self.requestKey = requestKey } public init( @@ -43,7 +46,8 @@ public struct ClassCreation: Equatable, Sendable { allowsAnonymousOpinion: Bool, requiresComment: Bool, ownerParticipates: Bool = false, - ownerDisplayName: String? = nil + ownerDisplayName: String? = nil, + requestKey: String? = nil ) { self.name = name self.deadline = deadline @@ -53,5 +57,6 @@ public struct ClassCreation: Equatable, Sendable { self.requiresComment = requiresComment self.ownerParticipates = ownerParticipates self.ownerDisplayName = ownerDisplayName + self.requestKey = requestKey } } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassMidVoteChoice.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassMidVoteChoice.swift new file mode 100644 index 00000000..18dba431 --- /dev/null +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassMidVoteChoice.swift @@ -0,0 +1,15 @@ +public struct ClassMidVoteChoice: Equatable, Sendable { + public let checkpointID: Int + public let optionID: Int + public let label: String + + public init( + checkpointID: Int, + optionID: Int, + label: String + ) { + self.checkpointID = checkpointID + self.optionID = optionID + self.label = label + } +} diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift index 9130be62..aed8ca44 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Entity/Vote/ClassVoteHistory.swift @@ -1,6 +1,7 @@ public struct ClassVoteHistory: Equatable, Sendable { public let battleTitle: String public let preVote: ClassVoteChoice + public let midVotes: [ClassMidVoteChoice] public let postVote: ClassVoteChoice? public let status: ClassBattleStep public let opinionChanged: Bool? @@ -8,12 +9,14 @@ public struct ClassVoteHistory: Equatable, Sendable { public init( battleTitle: String, preVote: ClassVoteChoice, + midVotes: [ClassMidVoteChoice] = [], postVote: ClassVoteChoice?, status: ClassBattleStep, opinionChanged: Bool? ) { self.battleTitle = battleTitle self.preVote = preVote + self.midVotes = midVotes self.postVote = postVote self.status = status self.opinionChanged = opinionChanged diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift index c78b435e..88e927eb 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Battle/ClassBattleInterface.swift @@ -11,6 +11,7 @@ import Foundation public protocol ClassBattleInterface: Sendable { func fetchBattle(classID: Int) async throws -> ClassBattleSession func submitPreVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt + func submitMidVote(classID: Int, checkpointID: Int, optionID: Int) async throws -> ClassVoteReceipt func completeTTS(classID: Int) async throws func submitPostVote(classID: Int, optionID: Int) async throws -> ClassVoteReceipt func fetchMyVotes(classID: Int) async throws -> ClassVoteHistory diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift index ee0404ce..31f59977 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Default/MockClassRepository+Features.swift @@ -9,6 +9,14 @@ public extension MockClassRepository { throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: submitPreVote") } + public func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: submitMidVote") + } + public func completeTTS(classID: Int) async throws { throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: completeTTS") } @@ -109,6 +117,13 @@ public extension MockClassRepository { throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchInsight") } + public func fetchMyActivity( + roomID: Int, + cursor: String? + ) async throws -> ClassMyActivity { + throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: fetchMyActivity") + } + public func retryInsight(roomID: Int) async throws { throw ClassError.unknown("모의 클래스 기능이 설정되지 않았습니다: retryInsight") } diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift index dac4b067..4719fb1a 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Result/ClassResultInterface.swift @@ -13,6 +13,10 @@ public protocol ClassResultInterface: Sendable { func fetchPersonalResult(roomID: Int) async throws -> ClassAIPersonalFeedback func fetchInsight(roomID: Int) async throws -> ClassAIInsight func retryInsight(roomID: Int) async throws + func fetchMyActivity( + roomID: Int, + cursor: String? + ) async throws -> ClassMyActivity } public enum ClassResultRepositoryDependency: TestDependencyKey { diff --git a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift index 748b4666..50fad1ad 100644 --- a/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift +++ b/Projects/Domain/ClassDomain/Interface/Sources/Repository/Room/ClassRoomInterface.swift @@ -23,7 +23,7 @@ public protocol ClassRoomInterface: Sendable { public extension ClassRoomInterface { func updateName(id _: Int, name _: String) async throws -> ClassRoom { - throw ClassError.unknown("클래스 이름 변경을 지원하지 않습니다.") + throw ClassError.userMessage("클래스 이름 변경을 지원하지 않습니다.") } func fetchClassPreview(joinCode: String) async throws -> ClassJoinPreview { diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift index 40bce229..d21d0003 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift @@ -37,6 +37,7 @@ struct ClassPersonalReportDTO: Decodable, Sendable { let state: ClassAIGenerationState let feedback: Feedback? let preOption: VoteOption? + let midVotes: [ClassMidVoteDTO]? let postOption: VoteOption? let perspectiveCount: Int? let commentCount: Int? diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift index c6ed6220..92d5d8ab 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassBattleSessionDTO.swift @@ -39,5 +39,6 @@ struct ClassBattleSessionDTO: Decodable, Sendable { let commentRequired: Bool let canParticipate: Bool let participationCompleted: Bool + let midVoteEnabled: Bool? let battle: Battle } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMyActivityDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMyActivityDTO.swift new file mode 100644 index 00000000..0e8544e5 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassMyActivityDTO.swift @@ -0,0 +1,20 @@ +import Foundation + +struct ClassMyActivityDTO: Decodable, Sendable { + struct Comment: Decodable, Sendable { + let commentId: Int? + let perspectiveId: Int? + let perspectiveTitle: String? + let stance: String? + let content: String? + let likeCount: Int? + let createdAt: String? + } + + let classId: Int? + let receivedLikeCount: Int? + let commentCount: Int? + let comments: [Comment]? + let nextCursor: String? + let hasNext: Bool? +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift index 1bb7d9b9..7d0d77f9 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassPerspectiveDTO.swift @@ -10,7 +10,7 @@ struct ClassPerspectiveReceiptDTO: Decodable, Sendable { struct ClassPerspectiveItemDTO: Decodable, Sendable { struct User: Decodable, Sendable { - let userTag: String + let userTag: String? let nickname: String let characterType: String? let characterImageUrl: String? diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift index e311e01a..fe3fd56d 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassResultDTO.swift @@ -4,6 +4,19 @@ import PickeCoreUtility struct ClassResultDTO: Decodable, Sendable { struct Stats: Decodable, Sendable { + struct MidVoteCheckpoint: Decodable, Sendable { + struct Option: Decodable, Sendable { + let optionId: Int + let label: String + let voteCount: Int + let ratio: Double + } + + let checkpointId: Int + let voteCount: Int + let options: [Option] + } + struct Option: Decodable, Sendable { let optionId: Int let title: String @@ -19,6 +32,9 @@ struct ClassResultDTO: Decodable, Sendable { let commentWriterCount: Int let commentRate: Double let postVoteCount: Int + let midVoteEnabled: Bool? + let midVoteCount: Int? + let midVoteCheckpoints: [MidVoteCheckpoint]? let options: [Option] } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift index 5f209386..ba24085e 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassVoteDTO.swift @@ -7,6 +7,12 @@ struct ClassVoteReceiptDTO: Decodable, Sendable { let status: String } +struct ClassMidVoteDTO: Decodable, Sendable { + let checkpointId: Int + let optionId: Int + let label: String +} + struct ClassVoteHistoryDTO: Decodable, Sendable { struct Choice: Decodable, Sendable { let optionId: Int @@ -17,6 +23,7 @@ struct ClassVoteHistoryDTO: Decodable, Sendable { let battleTitle: String let preVote: Choice + let midVotes: [ClassMidVoteDTO]? let postVote: Choice? let status: String let opinionChanged: Bool? diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift index 51494e86..120b44c6 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift @@ -21,6 +21,7 @@ extension ClassPersonalReportDTO.Feedback.Evidence { extension ClassPersonalReportDTO.Feedback { func toDomain( preOption: ClassPersonalReportDTO.VoteOption?, + midVotes: [ClassMidVoteDTO], postOption: ClassPersonalReportDTO.VoteOption?, perspectiveCount: Int, commentCount: Int @@ -38,6 +39,7 @@ extension ClassPersonalReportDTO.Feedback { coverage: coverage, excludedCount: excludedCount ?? 0, preOption: preOption?.toDomain(), + midVotes: midVotes.map { $0.toDomain() }, postOption: postOption?.toDomain(), perspectiveCount: perspectiveCount, commentCount: commentCount @@ -52,6 +54,7 @@ extension ClassPersonalReportDTO { guard let feedback else { throw ClassAIFeedbackError.generationFailed } return feedback.toDomain( preOption: preOption, + midVotes: midVotes ?? [], postOption: postOption, perspectiveCount: perspectiveCount ?? 0, commentCount: commentCount ?? 0 diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift index da1592e7..fb9ba900 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassBattleSessionDTO+.swift @@ -55,7 +55,8 @@ extension ClassBattleSessionDTO { commentRequired: commentRequired, canParticipate: canParticipate, participationCompleted: participationCompleted, - battle: try battle.toDomain() + battle: try battle.toDomain(), + midVoteEnabled: midVoteEnabled ?? false ) } } diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift index bf06f21d..f8168db2 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMemberDTO+.swift @@ -15,7 +15,7 @@ extension ClassMemberDTO { extension ClassMemberDTO { static func toMemberList(_ payload: [Self]) throws -> ClassMemberList { guard let current = payload.first(where: \.me) else { - throw ClassError.unknown("내 멤버 정보를 찾을 수 없어요.") + throw ClassError.userMessage("내 멤버 정보를 찾을 수 없어요.") } return ClassMemberList( members: payload.map { $0.toDomain() }, diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMyActivityDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMyActivityDTO+.swift new file mode 100644 index 00000000..74b860a7 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassMyActivityDTO+.swift @@ -0,0 +1,32 @@ +import ClassDomainInterface +import Foundation +import PickeCoreUtility + +extension ClassMyActivityDTO.Comment { + /// Android와 같이 댓글·의견 ID가 없는 항목은 버린다. + func toDomain() -> ClassMyActivityComment? { + guard let commentId, let perspectiveId else { return nil } + return ClassMyActivityComment( + id: commentId, + perspectiveID: perspectiveId, + perspectiveTitle: perspectiveTitle ?? "", + stance: stance ?? "", + content: content ?? "", + likeCount: likeCount ?? 0, + createdAt: createdAt.flatMap(ServerDateParser.parse) + ) + } +} + +extension ClassMyActivityDTO { + func toDomain(fallbackClassID: Int) -> ClassMyActivity { + ClassMyActivity( + classID: classId ?? fallbackClassID, + receivedLikeCount: receivedLikeCount ?? 0, + commentCount: commentCount ?? 0, + comments: comments?.compactMap { $0.toDomain() } ?? [], + nextCursor: nextCursor, + hasNext: hasNext ?? false + ) + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift index e99be720..beaf61eb 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassPerspectiveDTO+.swift @@ -14,7 +14,7 @@ extension ClassPerspectiveReceiptDTO { extension ClassPerspectiveItemDTO.User { func toDomain() -> ClassPerspectiveAuthor { ClassPerspectiveAuthor( - userTag: userTag, + userTag: userTag ?? "", nickname: nickname, characterType: characterType, characterImageURL: characterImageUrl.flatMap(URL.init(string:)) diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift index 6ed67760..b63d2679 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassResultDTO+.swift @@ -14,6 +14,27 @@ extension ClassResultDTO.Stats.Option { } } +extension ClassResultDTO.Stats.MidVoteCheckpoint.Option { + func toDomain() -> ClassResultMidVoteOption { + ClassResultMidVoteOption( + id: optionId, + label: label, + voteCount: voteCount, + ratio: ratio + ) + } +} + +extension ClassResultDTO.Stats.MidVoteCheckpoint { + func toDomain() -> ClassResultMidVoteCheckpoint { + ClassResultMidVoteCheckpoint( + id: checkpointId, + voteCount: voteCount, + options: options.map { $0.toDomain() } + ) + } +} + extension ClassResultDTO.Stats { func toDomain() -> ClassResultStats { ClassResultStats( @@ -23,6 +44,9 @@ extension ClassResultDTO.Stats { commentWriterCount: commentWriterCount, commentRate: commentRate, postVoteCount: postVoteCount, + midVoteEnabled: midVoteEnabled ?? false, + midVoteCount: midVoteCount ?? 0, + midVoteCheckpoints: midVoteCheckpoints?.map { $0.toDomain() } ?? [], options: options.map { $0.toDomain() } ) } diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift index bec26639..253e1ec1 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassTicketDTO+.swift @@ -4,8 +4,9 @@ import PickeCoreUtility extension ClassTicketDTO { func toDomain() throws -> ClassTicket { + // Android `ClassTicketStatus.from`과 같이 모르는 상태값은 만료로 본다. + let status = ClassTicket.Status(rawValue: self.status) ?? .expired guard - let status = ClassTicket.Status(rawValue: status), let activatedAt = ServerDateParser.parse(activatedAt), let expiresAt = ServerDateParser.parse(expiresAt), let readGraceEndsAt = ServerDateParser.parse(readGraceEndsAt) diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift index 14d33d7a..5d077754 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassVoteDTO+.swift @@ -17,6 +17,16 @@ extension ClassVoteHistoryDTO.Choice { } } +extension ClassMidVoteDTO { + func toDomain() -> ClassMidVoteChoice { + ClassMidVoteChoice( + checkpointID: checkpointId, + optionID: optionId, + label: label + ) + } +} + extension ClassVoteHistoryDTO { func toDomain() throws -> ClassVoteHistory { guard let status = ClassBattleStep(rawValue: status) else { @@ -25,6 +35,7 @@ extension ClassVoteHistoryDTO { return ClassVoteHistory( battleTitle: battleTitle, preVote: preVote.toDomain(), + midVotes: midVotes?.map { $0.toDomain() } ?? [], postVote: postVote?.toDomain(), status: status, opinionChanged: opinionChanged diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassErrorMapper.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassErrorMapper.swift index 195a9429..92326fd0 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassErrorMapper.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassErrorMapper.swift @@ -31,6 +31,7 @@ enum ClassErrorMapper { case "CLASS_404": return .notFound case "CLASS_409_FULL": return .full case "CLASS_409_DELETED": return .deleted + case "CLASS_409_REQUEST_KEY": return .requestKeyConflict case "CLASS_409_OWNER": return .ownClass case "CLASS_403_REMOVED": return .removed case "CLASS_403_TICKET": return .ticketRequired diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift index cbd1ae18..91e012c7 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Battle.swift @@ -40,6 +40,20 @@ extension ClassRepositoryImpl { } catch { throw Self.battleError(error) } } + public func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + do { + let payload = try await client.send( + ClassBattleService.midVote(classID: classID, checkpointID: checkpointID, optionID: optionID), + as: ClassVoteReceiptDTO.self + ) + return try payload.toDomain() + } catch { throw Self.battleError(error) } + } + public func submitPostVote( classID: Int, optionID: Int @@ -125,6 +139,11 @@ extension ClassRepositoryImpl { case "CLASS_409_PRE_VOTE_LOCKED": return .preVoteLocked case "BATTLE_OPT_404": return .invalidOption case "CLASS_409_TTS": return .ttsUnavailable + case "CLASS_409_TTS_INCOMPLETE": return .ttsIncomplete + case "CLASS_409_MID_VOTE": return .midVoteUnavailable + case "CLASS_404_MID_VOTE_OPTION": return .invalidMidVoteOption + case "CLASS_409_MID_VOTE_LOCKED": return .midVoteLocked + case "CLASS_409_MID_VOTE_REQUIRED": return .midVoteRequired case "VOTE_404": return .voteNotFound case "VOTE_409_PRE": return .preVoteRequired case "VOTE_409_SUB": return .alreadySubmitted diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift index 040b152f..50b1051c 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Recommendation.swift @@ -13,7 +13,7 @@ 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("추천 조건을 확인해 주세요.") + throw ClassError.userMessage("추천 조건을 확인해 주세요.") } do { let data = try await client.send( diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift index cbf51ed7..aacd394b 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift @@ -52,6 +52,26 @@ extension ClassRepositoryImpl { } } + public func fetchMyActivity( + roomID: Int, + cursor: String? + ) async throws -> ClassMyActivity { + do { + let payload = try await client.send( + ClassResultService.myActivity(roomID, cursor: cursor), + as: ClassMyActivityDTO.self + ) + return payload.toDomain(fallbackClassID: roomID) + } catch let error as PickeNetworkError { + if case let .response(response) = error { + throw Self.resultError(code: response.code, message: response.message) + } + throw ClassError.from(error) + } catch { + throw ClassError.from(error) + } + } + static func resultError( code: String?, message: String? diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift index 9434363c..80b9c3cd 100644 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Battle.swift @@ -17,6 +17,14 @@ extension ClassUseCaseImpl { try await repository.submitPreVote(classID: classID, optionID: optionID) } + public func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + try await repository.submitMidVote(classID: classID, checkpointID: checkpointID, optionID: optionID) + } + public func completeTTS(classID: Int) async throws { try await repository.completeTTS(classID: classID) } diff --git a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift index 6b6d188b..cdbf3fb9 100644 --- a/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift +++ b/Projects/Domain/ClassDomain/Sources/UseCase/ClassUseCaseImpl+Result.swift @@ -24,4 +24,11 @@ extension ClassUseCaseImpl { public func retryInsight(roomID: Int) async throws { try await repository.retryInsight(roomID: roomID) } + + public func fetchMyActivity( + roomID: Int, + cursor: String? + ) async throws -> ClassMyActivity { + try await repository.fetchMyActivity(roomID: roomID, cursor: cursor) + } } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassBattleAPIContractTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassBattleAPIContractTests.swift index 16779be3..b0e1eb9a 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassBattleAPIContractTests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassBattleAPIContractTests.swift @@ -10,6 +10,7 @@ struct ClassBattleAPIContractTests { func 클래스_배틀_경로와_투표_의견_요청_본문이_명세와_일치한다() throws { let detail = ClassBattleService.detail(classID: 5) let preVote = ClassBattleService.preVote(classID: 5, optionID: 31) + let midVote = ClassBattleService.midVote(classID: 5, checkpointID: 801, optionID: 9001) let postVote = ClassBattleService.postVote(classID: 5, optionID: 32) let publish = ClassBattleService.publishPerspective(classID: 5, content: "의견") let perspectives = ClassBattleService.perspectives( @@ -24,6 +25,8 @@ struct ClassBattleAPIContractTests { #expect(detail.method == .get) #expect(try preVote.url().path == "/api/v1/classes/5/battle/votes/pre") #expect(preVote.method == .post) + #expect(try midVote.url().path == "/api/v1/classes/5/battle/votes/mid") + #expect(midVote.method == .post) #expect(try postVote.url().path == "/api/v1/classes/5/battle/votes/post") #expect(postVote.method == .post) #expect(try ClassBattleService.ttsComplete(classID: 5).url().path == "/api/v1/classes/5/battle/votes/tts-complete") @@ -35,6 +38,7 @@ struct ClassBattleAPIContractTests { #expect(try ClassBattleService.myPerspectives(classID: 5).url().path == "/api/v1/classes/5/battle/perspectives/me") guard let preVoteParameters = preVote.parameters, + let midVoteParameters = midVote.parameters, let postVoteParameters = postVote.parameters, let publishParameters = publish.parameters, let perspectiveParameters = perspectives.parameters @@ -44,10 +48,12 @@ struct ClassBattleAPIContractTests { } let encoder = JSONEncoder() let preVoteJSON = try JSONSerialization.jsonObject(with: encoder.encode(preVoteParameters)) as? [String: Int] + let midVoteJSON = try JSONSerialization.jsonObject(with: encoder.encode(midVoteParameters)) as? [String: Int] let postVoteJSON = try JSONSerialization.jsonObject(with: encoder.encode(postVoteParameters)) as? [String: Int] let publishJSON = try JSONSerialization.jsonObject(with: encoder.encode(publishParameters)) as? [String: String] let perspectiveJSON = try JSONSerialization.jsonObject(with: encoder.encode(perspectiveParameters)) as? [String: Any] #expect(preVoteJSON == ["optionId": 31]) + #expect(midVoteJSON == ["checkpointId": 801, "optionId": 9001]) #expect(postVoteJSON == ["optionId": 32]) #expect(publishJSON == ["content": "의견"]) #expect(perspectiveJSON?["cursor"] as? String == "next") @@ -86,7 +92,7 @@ struct ClassBattleAPIContractTests { func 클래스_배틀_응답은_클래스_권한과_배틀_진행을_함께_복원한다() throws { let json = """ {"classId":5,"myRole":"MEMBER","closeAt":"2026-12-31T23:59:59","closed":false, - "commentRequired":true,"canParticipate":true,"participationCompleted":false, + "commentRequired":true,"canParticipate":true,"participationCompleted":false,"midVoteEnabled":true, "battle":{"battleInfo":{"battleId":9,"title":"AI가 쓴 글도 창작일까?", "options":[{"optionId":31,"label":"A","title":"창작이다"}]}, "userVoteStatus":"PRE_VOTE","currentStep":"PRE_VOTE"}} @@ -96,6 +102,7 @@ struct ClassBattleAPIContractTests { #expect(session.classID == 5) #expect(session.myRole == .member) #expect(session.canParticipate) + #expect(session.midVoteEnabled) #expect(session.battle.currentStep == .preVote) #expect(session.battle.options.first?.id == 31) } @@ -107,7 +114,7 @@ struct ClassBattleAPIContractTests { "postVote":{"optionId":32,"label":"B","title":"창작이 아니다"},"status":"COMPLETED","opinionChanged":true} """ let pageJSON = """ - {"items":[{"perspectiveId":50,"user":{"userTag":"u1","nickname":"피케"}, + {"items":[{"perspectiveId":50,"user":{"userTag":null,"nickname":"피케"}, "option":{"optionId":31,"label":"A","title":"창작이다"},"content":"의견", "likeCount":3,"commentCount":1,"isLiked":false,"isMyPerspective":true, "createdAt":"2026-10-21T12:00:00"}],"nextCursor":"next","hasNext":true} @@ -118,6 +125,7 @@ struct ClassBattleAPIContractTests { #expect(history.status == .completed) #expect(history.opinionChanged == true) #expect(page.items.first?.id == 50) + #expect(page.items.first?.author.userTag == "") #expect(page.items.first?.isMyPerspective == true) #expect(page.nextCursor == "next") #expect(page.hasNext) diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassErrorMappingTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassErrorMappingTests.swift index 1502babc..f2609eae 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassErrorMappingTests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassErrorMappingTests.swift @@ -13,6 +13,7 @@ struct ClassErrorMappingTests { ("CLASS_404", .notFound), ("CLASS_409_FULL", .full), ("CLASS_409_DELETED", .deleted), + ("CLASS_409_REQUEST_KEY", .requestKeyConflict), ("CLASS_409_OWNER", .ownClass), ("CLASS_403_REMOVED", .removed), ("CLASS_403_TICKET", .ticketRequired), @@ -43,4 +44,18 @@ struct ClassErrorMappingTests { func 메시지가_없으면_기본_문구를_보여준다() { #expect(ClassErrorMapper.map(code: nil, message: nil, fallback: "기본 문구") == .network("기본 문구")) } + + @Test + func 알수없는_내부_오류는_구현_상세를_화면에_표시하지_않는다() { + let error = ClassError.unknown("AuthenticationError.excessiveRefresh") + #expect(error.message == "클래스 요청을 처리하지 못했습니다. 다시 시도해 주세요.") + } + + @Test + func 알수없는_오류여도_명시적인_사용자_안내는_유지한다() { + let message = "AI 주제 생성 시간이 초과됐어요. 다시 확인해 주세요." + #expect(ClassError.userMessage(message).message == message) + #expect(ClassError.unknown("AuthenticationError.excessiveRefresh: 인증 토큰 갱신 실패").message != + "AuthenticationError.excessiveRefresh: 인증 토큰 갱신 실패") + } } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassMemberAPITests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassMemberAPITests.swift index 87d61e13..a2d9a270 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassMemberAPITests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassMemberAPITests.swift @@ -49,7 +49,7 @@ struct ClassMemberAPITests { [{"memberId":10,"displayName":"운영자","role":"OWNER","characterUrl":null,"me":false}] """ let payload = try JSONDecoder().decode([ClassMemberDTO].self, from: Data(json.utf8)) - #expect(throws: ClassError.unknown("내 멤버 정보를 찾을 수 없어요.")) { + #expect(throws: ClassError.userMessage("내 멤버 정보를 찾을 수 없어요.")) { try ClassMemberDTO.toMemberList(payload) } } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift index b9b4a243..f93bbff8 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassRecommendationTests.swift @@ -102,7 +102,8 @@ struct ClassRecommendationTests { battleId: 12, allowsAnonymousOpinion: false, requiresComment: true, - ownerParticipates: false + ownerParticipates: false, + requestKey: "3f2b8c1e-7d4a-4f6b-9a0e-2c5d8e1f4a7b" ) let request = ClassroomService.create(creation) guard let body = request.parameters else { @@ -120,6 +121,7 @@ struct ClassRecommendationTests { #expect(fields["deadline"] as? String == "2026-12-31T23:59:59") #expect((fields["commentRequired"] as? Bool) == true) #expect((fields["ownerParticipates"] as? Bool) == false) + #expect(fields["requestKey"] as? String == "3f2b8c1e-7d4a-4f6b-9a0e-2c5d8e1f4a7b") #expect(fields["allowsAnonymousOpinion"] == nil) } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassResultAPIContractTests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassResultAPIContractTests.swift index 53e31e9c..f32d4345 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassResultAPIContractTests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassResultAPIContractTests.swift @@ -44,6 +44,11 @@ struct ClassResultAPIContractTests { "stats": { "activeMemberCount": 12, "completedCount": 10, "participationRate": 83.3, "commentWriterCount": 9, "commentRate": 75.0, "postVoteCount": 11, + "midVoteEnabled": true, "midVoteCount": 10, + "midVoteCheckpoints": [{ + "checkpointId": 801, "voteCount": 10, + "options": [{"optionId": 9002, "label": "무죄", "voteCount": 6, "ratio": 60.0}] + }], "options": [ { "optionId": 31, "title": "창작이다", "preVoteCount": 8, "postVoteCount": 6, "postVoteRatio": 54.5 } @@ -59,6 +64,11 @@ struct ClassResultAPIContractTests { #expect(result.stats.completedCount == 10) #expect(result.stats.participationRate == 83.3) #expect(result.stats.options.first?.id == 31) + #expect(result.stats.midVoteEnabled) + #expect(result.stats.midVoteCount == 10) + #expect(result.stats.midVoteCheckpoints.first?.id == 801) + #expect(result.stats.midVoteCheckpoints.first?.options.first?.id == 9002) + #expect(result.stats.midVoteCheckpoints.first?.options.first?.ratio == 60.0) #expect(result.myReportState == nil) #expect(result.insightState == "SUCCEEDED") } @@ -84,6 +94,7 @@ struct ClassResultAPIContractTests { { "state": "SUCCEEDED", "preOption": { "optionId": 31, "title": "창작이다", "stance": "처음 의견" }, + "midVotes": [{"checkpointId":801,"optionId":9002,"label":"무죄"}], "postOption": { "optionId": 32, "title": "창작이 아니다", "stance": "바뀐 의견" }, "perspectiveCount": 1, "commentCount": 2, @@ -106,11 +117,35 @@ struct ClassResultAPIContractTests { #expect(feedback.evidence.first?.content == "삭제되지 않은 원문") #expect(feedback.evidence.first?.quote == "짧은 인용") #expect(feedback.preOption?.id == 31) + #expect(feedback.midVotes == [ClassMidVoteChoice(checkpointID: 801, optionID: 9002, label: "무죄")]) #expect(feedback.postOption?.title == "창작이 아니다") #expect(feedback.perspectiveCount == 1) #expect(feedback.commentCount == 2) } + @Test + func 투표_이력의_중간_선택을_보존한다() throws { + let json = """ + {"battleTitle":"Picke(TTS) 배틀","preVote":{"optionId":31,"label":"A","title":"창작이다"}, + "midVotes":[{"checkpointId":801,"optionId":9002,"label":"무죄"}], + "postVote":null,"status":"PRE_VOTE","opinionChanged":null} + """ + + let history = try JSONDecoder().decode(ClassVoteHistoryDTO.self, from: Data(json.utf8)).toDomain() + #expect(history.midVotes == [ClassMidVoteChoice(checkpointID: 801, optionID: 9002, label: "무죄")]) + } + + @Test + func 중간_투표가_없는_이전_응답도_빈_선택으로_읽는다() throws { + let json = """ + {"battleTitle":"일반 배틀","preVote":{"optionId":31,"label":"A","title":"창작이다"}, + "postVote":null,"status":"PRE_VOTE","opinionChanged":null} + """ + + let history = try JSONDecoder().decode(ClassVoteHistoryDTO.self, from: Data(json.utf8)).toDomain() + #expect(history.midVotes.isEmpty) + } + @Test func 운영자_인사이트의_근거와_대표_의견을_보존한다() throws { let json = """ @@ -128,4 +163,64 @@ struct ClassResultAPIContractTests { #expect(insight.evidence.first?.sourceID == 50) #expect(insight.highlights.first?.content == "대표 의견") } + + @Test + func 내_활동_경로와_커서_쿼리가_Android와_일치한다() throws { + let first = ClassResultService.myActivity(5, cursor: nil) + let next = ClassResultService.myActivity(5, cursor: "c-2") + #expect(first.path == "classes/5/result/my-activity") + #expect(try first.url().path == "/api/v1/classes/5/result/my-activity") + #expect(first.method == .get) + + let encoder = JSONEncoder() + guard let firstParameters = first.parameters, let nextParameters = next.parameters else { + Issue.record("내 활동 요청 파라미터가 없습니다.") + return + } + let firstJSON = try JSONSerialization.jsonObject(with: encoder.encode(firstParameters)) as? [String: Any] + let nextJSON = try JSONSerialization.jsonObject(with: encoder.encode(nextParameters)) as? [String: Any] + #expect(firstJSON?.isEmpty == true) + #expect(nextJSON?["cursor"] as? String == "c-2") + } + + @Test + func 내_활동_응답을_매핑하고_ID_없는_댓글은_버린다() throws { + let json = """ + { + "classId": 5, "receivedLikeCount": 12, "commentCount": 3, + "comments": [ + { "commentId": 71, "perspectiveId": 9, "perspectiveTitle": "처벌을 강화해야 한다", + "stance": "찬성", "content": "동의해요", "likeCount": 2, + "createdAt": "2026-11-02T10:00:00.000Z" }, + { "commentId": null, "perspectiveId": 9, "content": "버려질 댓글" }, + { "commentId": 72, "perspectiveId": 10 } + ], + "nextCursor": "c-2", "hasNext": true + } + """ + let activity = try JSONDecoder().decode(ClassMyActivityDTO.self, from: Data(json.utf8)) + .toDomain(fallbackClassID: 99) + #expect(activity.classID == 5) + #expect(activity.receivedLikeCount == 12) + #expect(activity.commentCount == 3) + #expect(activity.comments.map(\.id) == [71, 72]) + #expect(activity.comments.first?.perspectiveTitle == "처벌을 강화해야 한다") + #expect(activity.comments.first?.createdAt == ServerDateParser.parse("2026-11-02T10:00:00.000Z")) + #expect(activity.comments.last?.perspectiveTitle == "") + #expect(activity.comments.last?.likeCount == 0) + #expect(activity.comments.last?.createdAt == nil) + #expect(activity.nextCursor == "c-2") + #expect(activity.hasNext) + + let empty = try JSONDecoder().decode(ClassMyActivityDTO.self, from: Data("{}".utf8)) + .toDomain(fallbackClassID: 99) + #expect(empty == ClassMyActivity( + classID: 99, + receivedLikeCount: 0, + commentCount: 0, + comments: [], + nextCursor: nil, + hasNext: false + )) + } } diff --git a/Projects/Domain/ClassDomain/Tests/Sources/ClassTicketAPITests.swift b/Projects/Domain/ClassDomain/Tests/Sources/ClassTicketAPITests.swift index b466520e..6fd732c3 100644 --- a/Projects/Domain/ClassDomain/Tests/Sources/ClassTicketAPITests.swift +++ b/Projects/Domain/ClassDomain/Tests/Sources/ClassTicketAPITests.swift @@ -72,12 +72,12 @@ struct ClassTicketAPITests { } @Test - func 알_수_없는_이용권_상태는_잘못된_응답으로_처리한다() throws { + func 알_수_없는_이용권_상태는_만료로_처리한다() throws { let json = """ {"ticketId":3,"capacity":15,"status":"REVOKED","activatedAt":"2026-10-01T10:00:00",\ "expiresAt":"2027-04-01T10:00:00","remainingDays":0,"readGraceEndsAt":"2027-05-01T10:00:00"} """ let payload = try JSONDecoder().decode(ClassTicketDTO.self, from: Data(json.utf8)) - #expect(throws: ClassTicketError.invalidResponse) { try payload.toDomain() } + #expect(try payload.toDomain().status == .expired) } } diff --git a/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentReplyItem.swift b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentReplyItem.swift index 108711b2..dee59295 100644 --- a/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentReplyItem.swift +++ b/Projects/Domain/CommentDomain/Interface/Sources/Item/CommentReplyItem.swift @@ -14,6 +14,8 @@ public struct CommentReplyItem: Equatable, Identifiable { public var authorImageURL: String? public var timeAgo: String public var option: CommentOption + /// 서버 `stance` — Android 댓글 카드 배지 글자. + public var stance: String public var content: String public var likeCount: Int public var isLiked: Bool @@ -27,6 +29,7 @@ public struct CommentReplyItem: Equatable, Identifiable { authorImageURL: String? = nil, timeAgo: String, option: CommentOption, + stance: String = "", content: String, likeCount: Int, isLiked: Bool = false, @@ -39,6 +42,7 @@ public struct CommentReplyItem: Equatable, Identifiable { self.authorImageURL = authorImageURL self.timeAgo = timeAgo self.option = option + self.stance = stance self.content = content self.likeCount = likeCount self.isLiked = isLiked @@ -60,6 +64,7 @@ public struct CommentReplyItem: Equatable, Identifiable { authorImageURL: item.user.characterImageUrl, timeAgo: Self.relativeTimeString(from: item.createdAt), option: parentOption, + stance: item.stance, content: item.content, likeCount: item.likeCount, isLiked: item.isLiked, diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift index c0d88d85..de2b8b4d 100644 --- a/Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Core/HomeBundle.swift @@ -13,6 +13,7 @@ public struct HomeBundle: Equatable { public let heroes: [HeroBattle] public let hotBattles: [HotBattle] public let bestBattles: [BestBattle] + public let bestComments: [HomeBestComment] public let quizzes: [QuizQuestion] public let votes: [VoteQuestion] public let newBattles: [NewBattle] @@ -22,6 +23,7 @@ public struct HomeBundle: Equatable { heroes: [HeroBattle], hotBattles: [HotBattle], bestBattles: [BestBattle], + bestComments: [HomeBestComment] = [], quizzes: [QuizQuestion], votes: [VoteQuestion], newBattles: [NewBattle] @@ -30,6 +32,7 @@ public struct HomeBundle: Equatable { self.heroes = heroes self.hotBattles = hotBattles self.bestBattles = bestBattles + self.bestComments = bestComments self.quizzes = quizzes self.votes = votes self.newBattles = newBattles diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift index d71f39ee..a7fb6cfc 100644 --- a/Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/RecommendedPathKey.swift @@ -9,6 +9,8 @@ import Foundation public enum RecommendedPathKey: String, Equatable, Hashable, CaseIterable { case common = "COMMON" + case pathA = "PATH_A" + case pathB = "PATH_B" case unknown public init(rawValue: String) { diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift index 73182d79..25f8aa2b 100644 --- a/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Scenario/ScenarioInteractiveOption.swift @@ -8,16 +8,22 @@ import Foundation public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { + public let optionID: Int? public let label: String + public let title: String? public let nextNodeId: Int public var id: String { "\(label)-\(nextNodeId)" } public init( label: String, - nextNodeId: Int + nextNodeId: Int, + optionID: Int? = nil, + title: String? = nil ) { + self.optionID = optionID self.label = label + self.title = title self.nextNodeId = nextNodeId } } diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HotBattle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HotBattle.swift index e2e9f337..6cc900b4 100644 --- a/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HotBattle.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/HotBattle.swift @@ -35,6 +35,8 @@ public struct HotBattle: Equatable, Identifiable { } public var durationMinutes: Int { max(1, audioDuration / 60) } + /// 카드 태그 배지 문구 — 첫 태그, 없으면 "#이슈"(Android 와 동일). + public var tagLabel: String { "#\(tags.first?.name ?? "이슈")" } } public extension HotBattle { diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/NewBattle.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/NewBattle.swift index aad9fad0..bd2fa2aa 100644 --- a/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/NewBattle.swift +++ b/Projects/Domain/HomeDomain/Interface/Sources/Section/Battle/NewBattle.swift @@ -56,6 +56,8 @@ public struct NewBattle: Equatable, Identifiable { } public var durationMinutes: Int { max(1, audioDuration / 60) } + /// 카드 태그 배지 문구 — 첫 태그, 없으면 "#이슈"(Android 와 동일). + public var tagLabel: String { "#\(tags.first?.name ?? "이슈")" } } public extension NewBattle { diff --git a/Projects/Domain/HomeDomain/Interface/Sources/Section/Comment/HomeBestComment.swift b/Projects/Domain/HomeDomain/Interface/Sources/Section/Comment/HomeBestComment.swift new file mode 100644 index 00000000..918c55cc --- /dev/null +++ b/Projects/Domain/HomeDomain/Interface/Sources/Section/Comment/HomeBestComment.swift @@ -0,0 +1,30 @@ +// +// HomeBestComment.swift +// HomeDomainInterface +// + +import Foundation + +/// 홈 API가 제공하는 Best 댓글. 없는 battleId는 DTO 매핑에서 제외한다. +public struct HomeBestComment: Equatable, Identifiable { + public let battleId: Int + public let battleTitle: String + public let optionTitle: String + public let content: String + + public var id: Int { + battleId + } + + public init( + battleId: Int, + battleTitle: String, + optionTitle: String, + content: String + ) { + self.battleId = battleId + self.battleTitle = battleTitle + self.optionTitle = optionTitle + self.content = content + } +} diff --git a/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift index 0ab6a8b0..62c1e900 100644 --- a/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift +++ b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO+.swift @@ -63,6 +63,18 @@ public extension BestBattleDTO { } } +public extension BestCommentDTO { + func toDomain() -> HomeBestComment? { + guard let battleId else { return nil } + return HomeBestComment( + battleId: battleId, + battleTitle: battleTitle ?? "", + optionTitle: optionTitle ?? "", + content: content ?? "" + ) + } +} + public extension TodayQuizDTO { func toDomain() -> QuizQuestion { QuizQuestion( @@ -127,6 +139,7 @@ public extension HomeDataDTO { bestBattles: bestBattles.enumerated().map { idx, dto in dto.toDomain(rank: idx + 1) }, + bestComments: bestComments?.compactMap { $0.toDomain() } ?? [], quizzes: todayQuizzes.map { $0.toDomain() }, votes: todayVotes.map { $0.toDomain() }, newBattles: newBattles.map { $0.toDomain() } diff --git a/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift index ffafba67..3ff4891b 100644 --- a/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift +++ b/Projects/Domain/HomeDomain/Sources/Model/HomeDataDTO.swift @@ -15,11 +15,20 @@ public struct HomeDataDTO: Decodable { public let editorPicks: [EditorPickDTO] public let trendingBattles: [TrendingBattleDTO] public let bestBattles: [BestBattleDTO] + public let bestComments: [BestCommentDTO]? public let todayQuizzes: [TodayQuizDTO] public let todayVotes: [TodayVoteDTO] public let newBattles: [NewBattleDTO] } +/// Android 홈 계약의 선택 필드. 현재 서버가 아직 보내지 않는 경우에도 홈 로드는 유지한다. +public struct BestCommentDTO: Decodable { + public let battleId: Int? + public let battleTitle: String? + public let optionTitle: String? + public let content: String? +} + public struct TagDTO: Decodable, Equatable { public let tagId: Int public let name: String diff --git a/Projects/Domain/HomeDomain/Tests/Sources/BattleScenarioTimelineTests.swift b/Projects/Domain/HomeDomain/Tests/Sources/BattleScenarioTimelineTests.swift index 8ec83931..d65d7d6f 100644 --- a/Projects/Domain/HomeDomain/Tests/Sources/BattleScenarioTimelineTests.swift +++ b/Projects/Domain/HomeDomain/Tests/Sources/BattleScenarioTimelineTests.swift @@ -9,6 +9,11 @@ import HomeDomainInterface import Testing struct BattleScenarioTimelineTests { + @Test + func 추천_오디오_경로의_분기_키를_인식한다() { + #expect(RecommendedPathKey(rawValue: "PATH_A") == .pathA) + #expect(RecommendedPathKey(rawValue: "PATH_B") == .pathB) + } @Test func linearContentReachesClosingWithoutExplicitLink() { let scenario = makeScenario() diff --git a/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift b/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift index b826a746..8cde60e8 100644 --- a/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift +++ b/Projects/Domain/HomeDomain/Tests/Sources/HomeRepositoryTests.swift @@ -70,6 +70,10 @@ struct HomeRepositoryTests { "viewCount": 900 } ], + "bestComments": [ + { "battleId": 21, "battleTitle": "인간은 본래 선한가", "optionTitle": "선하다", "content": "선하게 태어나요" }, + { "battleId": null, "battleTitle": "제외", "optionTitle": "", "content": "" } + ], "todayQuizzes": [ { "battleId": 31, @@ -175,6 +179,12 @@ struct HomeRepositoryTests { #expect(bundle.bestBattles[1].rank == 2) #expect(bundle.bestBattles[1].battleId == 22) #expect(bundle.bestBattles[1].tags.isEmpty) + #expect(bundle.bestComments == [HomeBestComment( + battleId: 21, + battleTitle: "인간은 본래 선한가", + optionTitle: "선하다", + content: "선하게 태어나요" + )]) #expect(bundle.quizzes.count == 1) #expect(bundle.quizzes[0].isCorrectA == true) @@ -205,6 +215,7 @@ struct HomeRepositoryTests { #expect(bundle.heroes.isEmpty) #expect(bundle.hotBattles.isEmpty) #expect(bundle.bestBattles.isEmpty) + #expect(bundle.bestComments.isEmpty) #expect(bundle.quizzes.isEmpty) #expect(bundle.votes.isEmpty) #expect(bundle.newBattles.isEmpty) diff --git a/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift b/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift index b4e612ae..21820860 100644 --- a/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift +++ b/Projects/Domain/PerspectiveDomain/Interface/Sources/PerspectiveError.swift @@ -15,6 +15,8 @@ public enum PerspectiveError: LocalizedError, Equatable { case backendError(String) case serverError(Int) case unknown(String) + /// 서버 `REPORT_409`: 이미 신고한 의견. + case alreadyReported public var errorDescription: String? { switch self { @@ -25,6 +27,7 @@ public enum PerspectiveError: LocalizedError, Equatable { case let .backendError(message): return "perspective 처리 실패: \(message)" case let .serverError(code): return "서버 오류 (코드: \(code))" case let .unknown(message): return "알 수 없는 오류: \(message)" + case .alreadyReported: return "이미 신고한 사용자입니다." } } } diff --git a/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift b/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift index 9e0d5d72..b7dd9567 100644 --- a/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift +++ b/Projects/Domain/PerspectiveDomain/Sources/Repository/PerspectiveRepositoryImpl.swift @@ -120,16 +120,32 @@ public final class PerspectiveRepositoryImpl: PerspectiveInterface, @unchecked S } public func reportPerspective(perspectiveId: Int) async throws { - _ = try await client.send( - PerspectiveService.reportPerspective(perspectiveId: perspectiveId), - as: PickeEmptyResponse.self - ) + do { + _ = try await client.send( + PerspectiveService.reportPerspective(perspectiveId: perspectiveId), + as: PickeEmptyResponse.self + ) + } catch let error as PickeNetworkError { + // Android ReportPerspectiveUseCase와 같이 중복 신고는 서버 오류 코드로 구분한다. + if case let .response(response) = error, response.code == "REPORT_409" { + throw PerspectiveError.alreadyReported + } + throw error + } } public func reportComment(perspectiveId: Int, commentId: Int) async throws { - _ = try await client.send( - PerspectiveService.reportComment(perspectiveId: perspectiveId, commentId: commentId), - as: PickeEmptyResponse.self - ) + do { + _ = try await client.send( + PerspectiveService.reportComment(perspectiveId: perspectiveId, commentId: commentId), + as: PickeEmptyResponse.self + ) + } catch let error as PickeNetworkError { + // Android ReportCommentUseCase 도 중복 신고를 REPORT_409 로 구분한다. + if case let .response(response) = error, response.code == "REPORT_409" { + throw PerspectiveError.alreadyReported + } + throw error + } } } diff --git a/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift b/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift index 6df12f23..feb7a7ac 100644 --- a/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift +++ b/Projects/Domain/PerspectiveDomain/Tests/Sources/PerspectiveRepositoryTests.swift @@ -104,6 +104,52 @@ struct PerspectiveRepositoryTests { } } + // MARK: - reportPerspective + + @Test + func reportPerspective_duplicateCode_throwsAlreadyReported() async throws { + let json = """ + { + "statusCode": 409, + "data": null, + "error": { "code": "REPORT_409", "message": "이미 신고한 관점입니다" } + } + """ + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } + + await #expect(throws: PerspectiveError.alreadyReported) { + try await repo.reportPerspective(perspectiveId: 42) + } + } + + @Test + func reportPerspective_otherErrorCode_keepsNetworkResponseError() async throws { + let json = """ + { + "statusCode": 404, + "data": null, + "error": { "code": "PERSPECTIVE_404", "message": "존재하지 않는 관점입니다" } + } + """ + let repo = withDependencies { + $0.networkClient = StubNetworkClient(stubData: Data(json.utf8)) + } operation: { + PerspectiveRepositoryImpl() + } + + await expectNetworkResponseError( + statusCode: 404, + code: "PERSPECTIVE_404", + message: "존재하지 않는 관점입니다" + ) { + try await repo.reportPerspective(perspectiveId: 42) + } + } + // MARK: - fetchLabeledComments @Test diff --git a/Projects/Feature/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Feature/Chat/Sources/Comment/Reducer/CommentFeature.swift index 66509a96..65f28312 100644 --- a/Projects/Feature/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Feature/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -28,6 +28,12 @@ public struct CommentFeature { public var title: String = "" public var voteSummary: VoteSummary = .mock public var isLoadingStats: Bool = false + /// 첫 통계 응답을 받았는지 — 받기 전에는 Android `PerspectiveHeaderSkeleton` 을 그린다. + public var hasLoadedHeader: Bool = false + /// 첫 통계 조회 실패 — Android 처럼 통계·탭·목록 대신 `PickeLoadFailedSection` 을 그린다. + public var headerLoadFailed: Bool = false + /// Android `PerspectiveUiEvent` 토스트 — 뷰가 띄운 뒤 `toastShown` 으로 비운다. + public var toastMessage: String? public var isLoadingComments: Bool = false public var isSubmitting: Bool = false public var nextCursor: String? @@ -91,9 +97,28 @@ public struct CommentFeature { /// 사후투표 후엔 칩을 항상 노출하되, 이 값에 따라 문구만 분기한다. public var isMindChanged: Bool = false - /// 생각 변화 칩 문구 — 바뀜/안 바뀜 양쪽 모두 노출. + /// 생각 변화 칩 문구 — Android `comment_opinion_changed` / `comment_opinion_same`. public var changeBadgeTitle: String { - isMindChanged ? "생각이 바뀌었어요" : "생각이 바뀌지 않았어요" + isMindChanged ? "생각이 바뀌었어요" : "생각이 동일해요" + } + + /// Android 앱바 제목 — 배틀 제목, 비면 "관점 남기기". + public var navigationTitle: String { + title.isEmpty ? "관점 남기기" : title + } + + /// Android `comment_empty_all` / `comment_empty_option`. + public var emptyMessage: String { + switch selectedFilter { + case .all: return "아직 작성된 관점이 없습니다" + case .optionA: return "아직 작성된 \"\(voteSummary.optionA.title)\" 관점이 없습니다" + case .optionB: return "아직 작성된 \"\(voteSummary.optionB.title)\" 관점이 없습니다" + } + } + + /// Android `comment_input_hint_perspective` / `comment_input_hint_edit`. + public var inputPlaceholder: String { + isEditingComment ? "수정할 내용을 입력해주세요..." : "의견을 남겨보세요..." } public init(battleId: Int = 0, isMindChanged: Bool = false) { @@ -126,6 +151,8 @@ public struct CommentFeature { case sendTapped case composeTapped case composeDismissed + case lastCommentAppeared + case toastShown } public enum CommentMenuAction: Equatable { @@ -161,7 +188,8 @@ public struct CommentFeature { case perspectivesResponse(Result, reset: Bool) case likeResponse(Result) case createCommentResponse(Result) - case mutationFinished + case mutationResponse(Result) + case reportResponse(Result) case perspectiveLikesResponse(Result) } @@ -261,10 +289,12 @@ extension CommentFeature { guard let index = state.comments.firstIndex(where: { $0.id == id }), let commentId = state.comments[index].perspectiveId else { return .none } - let wasLiked = state.comments[index].isLiked - state.comments[index].isLiked.toggle() - state.comments[index].likeCount += state.comments[index].isLiked ? 1 : -1 - return .send(.async(.toggleLike(commentId: commentId, currentlyLiked: wasLiked))) + // Android: 본인 관점은 좋아요 대신 안내 토스트, 좋아요 수·상태는 응답을 받은 뒤 반영한다. + if state.comments[index].isMine { + state.toastMessage = "본인이 쓴 관점에는 좋아요를 누를 수 없습니다." + return .none + } + return .send(.async(.toggleLike(commentId: commentId, currentlyLiked: state.comments[index].isLiked))) } case .menuDismissed: @@ -330,6 +360,15 @@ extension CommentFeature { state.isComposerExpanded = true return .none + case .lastCommentAppeared: + // Android PerspectiveListSection: 마지막 항목 + 로딩 중 아님 + hasNext → 다음 페이지. + guard state.hasNext, !state.isLoadingComments, state.nextCursor != nil else { return .none } + return .send(.async(.fetchPerspectives(reset: false))) + + case .toastShown: + state.toastMessage = nil + return .none + case .composeDismissed: guard !state.isSubmitting else { return .none } state.isComposerExpanded = false @@ -489,19 +528,32 @@ extension CommentFeature { case let .updatePerspective(perspectiveId, content): return .run { [repository = perspectiveUseCase] send in - try? await repository.updatePerspective(perspectiveId: perspectiveId, content: content) - await send(.inner(.mutationFinished)) + let result = await Result { + try await repository.updatePerspective(perspectiveId: perspectiveId, content: content) + return true + } + .mapError(PerspectiveError.from) + await send(.inner(.mutationResponse(result))) } case let .deletePerspective(perspectiveId): return .run { [repository = perspectiveUseCase] send in - try? await repository.deletePerspective(perspectiveId: perspectiveId) - await send(.inner(.mutationFinished)) + let result = await Result { + try await repository.deletePerspective(perspectiveId: perspectiveId) + return true + } + .mapError(PerspectiveError.from) + await send(.inner(.mutationResponse(result))) } case let .reportPerspective(perspectiveId): - return .run { [repository = perspectiveUseCase] _ in - try? await repository.reportPerspective(perspectiveId: perspectiveId) + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.reportPerspective(perspectiveId: perspectiveId) + return true + } + .mapError(PerspectiveError.from) + await send(.inner(.reportResponse(result))) } case let .fetchPerspectiveLikes(perspectiveId): @@ -565,6 +617,7 @@ extension CommentFeature { } case let .failure(error): PickeLogger.error("[CommentFeature] fetchMyPerspective failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.loadFailedMessage } return .none @@ -573,8 +626,15 @@ extension CommentFeature { switch result { case let .success(stats): state.voteSummary = makeSummary(from: stats, fallback: state.voteSummary) + state.hasLoadedHeader = true + state.headerLoadFailed = false case let .failure(error): PickeLogger.error("[CommentFeature] fetchVoteStats failed: \(error.localizedDescription)", category: .ui) + if !state.hasLoadedHeader { + state.headerLoadFailed = true + } + state.hasLoadedHeader = true + state.toastMessage = Self.loadFailedMessage } return .none @@ -596,14 +656,21 @@ extension CommentFeature { } return comment } - state.comments = reset ? mapped : state.comments + mapped + if reset { + state.comments = mapped + } else { + let existing = Set(state.comments.compactMap(\.perspectiveId)) + state.comments += mapped.filter { $0.perspectiveId.map { !existing.contains($0) } ?? true } + } state.nextCursor = page.nextCursor - state.hasNext = page.hasNext + // Android: hasNext && nextCursor != nil && 이번 페이지가 비어 있지 않을 때만 다음 페이지. + state.hasNext = page.hasNext && page.nextCursor != nil && !page.items.isEmpty // 좋아요 수/상태(likeCount·isLiked)는 목록 응답 값을 그대로 신뢰한다. // (이전엔 댓글마다 GET /perspectives/{id}/likes 로 덮어써 수치가 로드 후 바뀌는 문제가 있었음) return .none case let .failure(error): PickeLogger.error("[CommentFeature] fetchPerspectives failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.loadFailedMessage } return .none @@ -616,6 +683,7 @@ extension CommentFeature { } case let .failure(error): PickeLogger.error("[CommentFeature] toggleLike failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage } return .none @@ -637,15 +705,32 @@ extension CommentFeature { return .send(.async(.fetchPerspectives(reset: true))) case let .failure(error): PickeLogger.error("[CommentFeature] createComment failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage return .none } - case .mutationFinished: - // 관점 수정/삭제 완료 → 입력 상태 복구 + 목록 갱신 + case let .mutationResponse(result): + // 관점 수정/삭제 완료 → 입력 상태 복구 + 목록 갱신. 실패는 Android 처럼 안내 토스트. state.isSubmitting = false state.isComposerExpanded = false + if case let .failure(error) = result { + PickeLogger.error("[CommentFeature] mutation failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage + } return .send(.async(.fetchPerspectives(reset: true))) + case let .reportResponse(result): + switch result { + case .success: + state.toastMessage = "신고가 정상 접수되었습니다." + case .failure(.alreadyReported): + state.toastMessage = "이미 신고한 사용자입니다." + case let .failure(error): + PickeLogger.error("[CommentFeature] report failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage + } + return .none + case let .perspectiveLikesResponse(result): if case let .success(payload) = result, let index = state.comments.firstIndex(where: { $0.perspectiveId == payload.perspectiveId }) @@ -657,6 +742,9 @@ extension CommentFeature { } } + static let loadFailedMessage = "정보를 불러오지 못했어요" + static let actionFailedMessage = "요청을 처리하지 못했어요" + /// API 응답(BattleVoteStats) 을 화면 모델 VoteSummary 로 매핑. /// 옵션이 2개 이상이라 가정 — 부족하거나 매핑 실패 시 fallback 유지. private func makeSummary( diff --git a/Projects/Feature/Chat/Sources/Comment/View/ClassCommentView.swift b/Projects/Feature/Chat/Sources/Comment/View/ClassCommentView.swift index dc5573e3..1a39c68a 100644 --- a/Projects/Feature/Chat/Sources/Comment/View/ClassCommentView.swift +++ b/Projects/Feature/Chat/Sources/Comment/View/ClassCommentView.swift @@ -50,6 +50,11 @@ public struct ClassCommentView: View { isCommentFocused = editingId != nil } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + .onChange(of: store.toastMessage, initial: true) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } } @ViewBuilder diff --git a/Projects/Feature/Chat/Sources/Comment/View/CommentView.swift b/Projects/Feature/Chat/Sources/Comment/View/CommentView.swift index a0e2c1d0..d615c4bc 100644 --- a/Projects/Feature/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Feature/Chat/Sources/Comment/View/CommentView.swift @@ -25,10 +25,17 @@ public struct CommentView: View { public var body: some View { VStack(spacing: 0) { headerSection() - commentScroll + if store.headerLoadFailed { + // Android: 첫 조회 실패 시 통계·탭·목록 대신 실패 표시만 남는다. + PickeLoadFailedSection() + .padding(16) + .frame(maxHeight: .infinity, alignment: .top) + } else { + commentScroll + } inputBar() } - .screenBackground() + .background(.bgBeige) .contentShape(Rectangle()) .onTapGesture { isCommentFocused = false @@ -45,26 +52,35 @@ public struct CommentView: View { isCommentFocused = editingID != nil } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + .onChange(of: store.toastMessage, initial: true) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } } } private extension CommentView { @ViewBuilder func headerSection() -> some View { - // Figma 8661-8291: 네비바·투표요약·필터탭은 고정, 아래 List 영역만 스크롤한다. - VStack(spacing: 4) { + // Android PerspectiveScreen: 상단 바·통계·탭은 고정, 아래 목록만 스크롤한다. 배경 backgroundBeige. + VStack(spacing: 0) { navigationBar - summarySection() - filterTabs() + if !store.hasLoadedHeader { + CommentHeaderSkeletonView() + } else if !store.headerLoadFailed { + summarySection() + filterTabs() + } } - .background(.beige200) + .background(.bgBeige) } var commentScroll: some View { ScrollView(showsIndicators: false) { commentList() } - .background(.surfaceBeigeDefault) + .background(.bgBeige) .scrollDismissesKeyboard(.interactively) .simultaneousGesture( DragGesture(minimumDistance: 24) @@ -81,46 +97,24 @@ private extension CommentView { ) } - /// 행 안쪽에서 위로 열리는 댓글 메뉴. - @ViewBuilder + func opensMenuUpward(_ comment: CommentItem) -> Bool { + comment.id == store.comments.last?.id && store.comments.count > 1 + } + + /// "…" 버튼에서 펼치는 댓글 메뉴 — Android PickeMoreMenu. func inlineMenu(for comment: CommentItem) -> some View { - VStack(alignment: .trailing, spacing: 8) { - ForEach(menuItems(for: comment)) { item in - Button { item.action() } label: { - HStack(spacing: 4) { - Image(systemName: item.systemImage) - .font(.system(size: 13, weight: .medium)) - Text(item.title) - .pretendardFont(.medium13) - } - .foregroundStyle(.beige50) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(.primary500, in: Capsule()) - } - .buttonStyle(.plain) - } - } - .padding(.trailing, 4) + PickeMoreMenuView(items: menuItems(for: comment)) } - /// "…" 메뉴 항목 — 내 글: 수정/삭제, 남 글: 신고. - func menuItems(for comment: CommentItem) -> [BottomActionItem] { + /// "…" 메뉴 항목 — Android 순서: 내 글 삭제·수정, 남 글 신고. + func menuItems(for comment: CommentItem) -> [PickeMoreMenuItem] { if comment.isMine { return [ - BottomActionItem(title: "수정", systemImage: "pencil") { send(.commentMenu(id: comment.id, action: .edit)) }, - BottomActionItem(title: "삭제", systemImage: "trash", isDestructive: true) { send(.commentMenu( - id: comment.id, - action: .delete - )) }, - ] - } else { - return [ - BottomActionItem(title: "신고", systemImage: "light.beacon.max.fill", isDestructive: true) { - send(.commentMenu(id: comment.id, action: .report)) - }, + .delete { send(.commentMenu(id: comment.id, action: .delete)) }, + .edit { send(.commentMenu(id: comment.id, action: .edit)) }, ] } + return [.report { send(.commentMenu(id: comment.id, action: .report)) }] } } @@ -128,11 +122,24 @@ private extension CommentView { private extension CommentView { var navigationBar: some View { + // Android PickeTopAppBar — 제목(비면 "관점 남기기") 16 SemiBold textDefault, 오른쪽 다음(ic_arrow_right 16). + // Android 는 시스템 뒤로 가기만 있어 앱바에 뒤로 버튼이 없지만, iOS 는 시스템 버튼이 없어 남긴다. PickeNavigationBar( onBack: { send(.backButtonTapped) }, - centerTitle: store.title - ) - .foregroundStyle(.cardBaseTextTitle) + centerTitle: store.navigationTitle + ) { + Button { send(.forwardTapped) } label: { + Image(asset: .arrowRight) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .accessibilityLabel("다음") + } + .foregroundStyle(.textDefault) } } @@ -147,15 +154,16 @@ private extension CommentView { imageUrl: store.voteSummary.optionA.imageUrl, representative: store.voteSummary.optionA.representative, title: store.voteSummary.optionA.title, - percentage: store.voteSummary.optionA.percentage + rate: store.voteSummary.optionA.rateLabel ) // Spacer 를 쓰면 컬럼이 남은 화면 높이를 전부 차지해 뱃지와 비율바가 벌어진다. // 고정 간격으로 묶어 양옆 옵션 컬럼 높이에 맞춘다. - VStack(spacing: 24) { + VStack(spacing: 0) { changeBadge() voteProgress() .padding(.horizontal, 8) + .frame(height: 35) } .padding(.top, 10) .frame(maxWidth: .infinity) @@ -164,7 +172,7 @@ private extension CommentView { imageUrl: store.voteSummary.optionB.imageUrl, representative: store.voteSummary.optionB.representative, title: store.voteSummary.optionB.title, - percentage: store.voteSummary.optionB.percentage + rate: store.voteSummary.optionB.rateLabel ) } .padding(.horizontal, 16) @@ -176,7 +184,7 @@ private extension CommentView { imageUrl: String?, representative: String, title: String, - percentage: Double + rate: String ) -> some View { VStack(spacing: 4) { avatarCircle(imageUrl: imageUrl, name: representative) @@ -184,11 +192,11 @@ private extension CommentView { VStack(spacing: 0) { Text(title) .pretendardFont(.medium11) - .foregroundStyle(.gray500) + .foregroundStyle(.textSubtler) .lineLimit(1) - Text(percentText(percentage)) + Text(rate) .pretendardFont(.semiBold12) - .foregroundStyle(.gray500) + .foregroundStyle(.textSubtler) } .frame(maxWidth: .infinity) } @@ -197,16 +205,19 @@ private extension CommentView { @ViewBuilder func changeBadge() -> some View { - HStack(spacing: 4) { - Image(systemName: "lightbulb") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.primary500) + // Android PerspectiveOpinionBadge — ic_think 16 + 12 SemiBold textPrimary, primary50 radius 2, 가로 4, 최소 높이 20. + HStack(spacing: 2) { + Image(asset: .think) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) Text(store.changeBadgeTitle) - .pretendardFont(.semiBold11) - .foregroundStyle(.primary500) + .pretendardFont(.semiBold12) } + .foregroundStyle(.textPrimary) .padding(.horizontal, 4) - .padding(.vertical, 2) + .frame(minHeight: 20) .roundedBackground(.primary50) } @@ -215,10 +226,10 @@ private extension CommentView { GeometryReader { proxy in let leftWidth = proxy.size.width * store.voteSummary.optionA.percentage ZStack(alignment: .leading) { - Capsule() + RoundedRectangle(cornerRadius: .radiusDefault) .fill(.beige600) .frame(height: 6) - Capsule() + RoundedRectangle(cornerRadius: .radiusDefault) .fill(.primary500) .frame(width: hasAnimatedVoteProgress ? max(0, leftWidth) : 0, height: 6) } @@ -293,46 +304,49 @@ private extension CommentView { private extension CommentView { @ViewBuilder func commentList() -> some View { - VStack(spacing: 0) { + LazyVStack(spacing: 0) { sortRow() .padding(.horizontal, 16) + .padding(.top, 16) .padding(.bottom, 6) if store.isLoadingComments, store.comments.isEmpty { CommentSkeletonView() - .padding(.horizontal, 16) } else if store.comments.isEmpty { emptyState() - .padding(.horizontal, 16) } else { ForEach(store.comments) { comment in commentCard(comment) - .overlay(alignment: .bottomTrailing) { - if store.menuTargetCommentID == comment.id { - inlineMenu(for: comment) - .padding(.bottom, 28) - .padding(.trailing, 16) - .zIndex(1) + .zIndex(store.menuTargetCommentID == comment.id ? 1 : 0) + .onAppear { + // Android: 마지막 항목이 보이면 다음 페이지(hasNext)를 부른다. + if comment.id == store.comments.last?.id { + send(.lastCommentAppeared) } } - .zIndex(store.menuTargetCommentID == comment.id ? 1 : 0) } .animation(.easeInOut(duration: 0.18), value: store.menuTargetCommentID) + + if store.isLoadingComments { + ProgressView() + .tint(.primary900) + .frame(maxWidth: .infinity) + .padding(16) + } } } - .padding(.vertical, 16) - .padding(.bottom, 24) } @ViewBuilder func emptyState() -> some View { - PickeEmptyStateView(message: "아직 작성된 관점이 없습니다") - .padding(.vertical, isCommentFocused ? 16 : 60) + // Android PickeEmptyState(fillParentMaxSize) — 정렬 줄 아래 남은 높이 가운데. + PickeEmptyStateView(message: store.emptyMessage) + .containerRelativeFrame(.vertical) { height, _ in max(0, height - 60) } } @ViewBuilder func commentCard(_ comment: CommentItem) -> some View { - // Figma 8661-8331: 흰 배경 + 하단 구분선만, py12 / 내부 gap8. + // Android PickeCommentCard — surfaceBeigeDefault, 안쪽 가로 16·세로 12, 간격 8, 아래 1 구분선 borderBeigeDefault. VStack(alignment: .leading, spacing: 8) { commentHeader(comment) @@ -344,18 +358,26 @@ private extension CommentView { commentActions(comment) } .padding(.horizontal, 16) - .pickeListRowCard() + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + // 구분선은 내용 아래에 그려 펼친 메뉴를 가리지 않게 한다. + .background(alignment: .bottom) { + ZStack(alignment: .bottom) { + Color.surfaceBeigeDefault + PickeDivider(.borderBeigeDefault) + } + } } @ViewBuilder func commentBody(_ comment: CommentItem) -> some View { Button { send(.commentRow(id: comment.id, action: .openReply)) } label: { Text(comment.content) - .pretendardFont(.regular13) - .foregroundStyle(.cardBaseTextBody) - .lineSpacing(13 * 0.4) - // 전체 내용은 답글 화면에서 확인한다. - .lineLimit(2) + .pretendardFont(.regular13, lineSpacingMultiple: 1.4) + .foregroundStyle(.textMuted) + // Android CONTENT_MAX_LINES = 3. 전체 내용은 답글 화면에서 확인한다. + .lineLimit(3) + .truncationMode(.tail) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 2) .frame(maxWidth: .infinity, alignment: .leading) @@ -383,12 +405,13 @@ private extension CommentView { VStack(alignment: .leading, spacing: 0) { Text(comment.isMine ? "나" : comment.author) .pretendardFont(.semiBold12) - .foregroundStyle(.cardBaseTextTitle) + .foregroundStyle(.textDefault) .lineLimit(1) + .truncationMode(.tail) Text(comment.timeAgo) .pretendardFont(.labelXSmall) - .foregroundStyle(.cardBaseTextDecription) + .foregroundStyle(.textMuted) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) @@ -406,24 +429,32 @@ private extension CommentView { @ViewBuilder func commentActions(_ comment: CommentItem) -> some View { - // Figma 8661-8352: 좋아요·답글수는 좌측 gap11, "…" 메뉴는 우측. - HStack(spacing: 0) { - HStack(spacing: 11) { - likeButton(comment) - replyCountButton(comment) - } - Spacer() + // Android: 좋아요·답글 수는 왼쪽 간격 8, "…"(ic_more 24)는 오른쪽. + HStack(spacing: 8) { + likeButton(comment) + replyCountButton(comment) + Spacer(minLength: 0) moreButton(commentId: comment.id) + .overlay(alignment: opensMenuUpward(comment) ? .bottomTrailing : .topTrailing) { + // Android DropdownMenu — "…" 버튼 바로 밑에서 펼치고, 아래 자리가 없으면(마지막 항목) 위로 펼친다. + if store.menuTargetCommentID == comment.id { + inlineMenu(for: comment) + .fixedSize() + .offset(y: opensMenuUpward(comment) ? -24 : 24) + .transition(.opacity) + } + } } } @ViewBuilder func moreButton(commentId: UUID) -> some View { Button { send(.commentMenu(id: commentId, action: .more)) } label: { - Image(systemName: "ellipsis") - .font(.system(size: 18, weight: .regular)) - .foregroundStyle(.cardBaseTextDecription) + Image(asset: .more) + .renderingMode(.template) + .resizable() .frame(width: 24, height: 24) + .foregroundStyle(.textMuted) } .buttonStyle(.plain) } @@ -432,15 +463,15 @@ private extension CommentView { func replyCountButton(_ comment: CommentItem) -> some View { Button { send(.commentRow(id: comment.id, action: .openReply)) } label: { HStack(spacing: 2) { - Image(systemName: "message") - .font(.system(size: 14, weight: .medium)) + Image(asset: .commentMessage) + .renderingMode(.template) + .resizable() + .scaledToFit() .frame(width: 16, height: 16) - Text("\(comment.replyCount)") + Text(comment.replyCount.decimalFormatted) .pretendardFont(.labelSmall) } - .foregroundStyle(.cardBaseTextDecription) - .padding(.horizontal, 6) - .padding(.vertical, 4) + .foregroundStyle(.textMuted) } .buttonStyle(.plain) } @@ -456,7 +487,7 @@ private extension CommentView { Text(comment.likeCount.decimalFormatted) .pretendardFont(.labelSmall) } - .foregroundStyle(comment.isLiked ? .primary500 : .cardBaseTextDecription) + .foregroundStyle(comment.isLiked ? .primary500 : .textMuted) } .buttonStyle(.plain) } @@ -493,7 +524,7 @@ private extension CommentView { PickeCommentInputBar( text: $store.commentText, focus: $isCommentFocused, - placeholder: "내용을 입력하세요", + placeholder: store.inputPlaceholder, isSendEnabled: store.isSendEnabled, isSubmitting: store.isSubmitting, isCompact: true, @@ -506,14 +537,6 @@ private extension CommentView { } } -// MARK: - Format - -private extension CommentView { - func percentText(_ percentage: Double) -> String { - "\(String(format: "%.1f", percentage * 100))%" - } -} - #Preview { CommentView( store: Store(initialState: CommentFeature.State()) { diff --git a/Projects/Feature/Chat/Sources/Comment/View/Components/CommentHeaderSkeletonView.swift b/Projects/Feature/Chat/Sources/Comment/View/Components/CommentHeaderSkeletonView.swift new file mode 100644 index 00000000..89a850c1 --- /dev/null +++ b/Projects/Feature/Chat/Sources/Comment/View/Components/CommentHeaderSkeletonView.swift @@ -0,0 +1,56 @@ +// +// CommentHeaderSkeletonView.swift +// Chat +// +// Created by Picke on 11/10/26. +// + +import SwiftUI + +import PickeDesignKit + +/// Android `PerspectiveHeaderSkeleton` + `PerspectiveTabBarSkeleton` — 첫 조회 중 통계·탭 자리. +struct CommentHeaderSkeletonView: View { + var body: some View { + VStack(spacing: 0) { + statsRow() + SkeletonView.line(width: 64, height: 20) + .frame(maxWidth: .infinity) + .padding(16) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.borderBeigeDefault) + .frame(height: 2) + } + } + .allowsHitTesting(false) + } + + @ViewBuilder + private func statsRow() -> some View { + HStack(spacing: 8) { + sideColumn() + VStack(spacing: 24) { + SkeletonView.line(width: 96, height: 20) + SkeletonView.beige(.round(cornerRadius: 3)) + .frame(height: 6) + } + .frame(maxWidth: .infinity) + sideColumn() + } + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + + @ViewBuilder + private func sideColumn() -> some View { + VStack(spacing: 0) { + SkeletonView.beige(.circle) + .frame(width: 40, height: 40) + SkeletonView.line(width: 40, height: 14) + .padding(.top, 2) + SkeletonView.line(width: 28, height: 16) + } + .frame(width: 64) + } +} diff --git a/Projects/Feature/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift b/Projects/Feature/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift index 28cb848f..adfbfb19 100644 --- a/Projects/Feature/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift +++ b/Projects/Feature/Chat/Sources/Comment/View/Components/CommentSkeletonView.swift @@ -9,34 +9,52 @@ import SwiftUI import PickeDesignKit +/// Android `PerspectiveListSkeleton` — 카드 4개, 가로 16·세로 12, 간격 12. struct CommentSkeletonView: View { - var count: Int = 3 - var body: some View { VStack(spacing: 12) { - ForEach(0 ..< count, id: \.self) { _ in + ForEach(0 ..< 4, id: \.self) { _ in card() } } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .allowsHitTesting(false) } @ViewBuilder private func card() -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - SkeletonView(.round(cornerRadius: 14)) - .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + SkeletonView.beige(.circle) + .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 4) { - SkeletonView(.round(cornerRadius: 4)).frame(width: 80, height: 12) - SkeletonView(.round(cornerRadius: 4)).frame(width: 48, height: 10) + SkeletonView.line(width: 40, height: 16) + SkeletonView.line(width: 50, height: 12) } - Spacer() + Spacer(minLength: 0) + } + SkeletonView.line(width: 70, height: 20) + .padding(.top, 8) + SkeletonView.line(width: 260, height: 16) + .padding(.top, 12) + SkeletonView.line(width: 200, height: 16) + .padding(.top, 4) + HStack(spacing: 0) { + SkeletonView.line(width: 40, height: 14) + Spacer(minLength: 0) + SkeletonView.line(width: 24, height: 14) + SkeletonView.line(width: 24, height: 14) + .padding(.leading, 12) } - SkeletonView(.round(cornerRadius: 4)).frame(maxWidth: .infinity).frame(height: 12) - SkeletonView(.round(cornerRadius: 4)).frame(width: 200, height: 12) + .padding(.top, 12) } - .padding(12) + .padding(14) .frame(maxWidth: .infinity, alignment: .leading) - .pickeCard(.beige50, border: .beige600) + .pickeCard( + .surfaceBeigeDefault, + border: .borderBeigeDefault, + insideBorder: true + ) } } diff --git a/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift b/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift index 06c94537..b402426b 100644 --- a/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift +++ b/Projects/Feature/Chat/Sources/CommentReply/Reducer/CommentReplyFeature.swift @@ -49,6 +49,15 @@ public struct CommentReplyFeature { public var deleteParentPending: Bool = false public var reportParentPending: Bool = false @Presents public var customAlert: CustomAlertState? + /// Android `CommentUiEvent` 토스트 — 뷰가 띄운 뒤 `toastShown` 으로 비운다. + public var toastMessage: String? + /// 원본 관점·첫 답글 조회 실패 — Android 처럼 내용 대신 `PickeLoadFailedSection` 을 그린다. + public var loadFailed: Bool = false + + /// Android `comment_input_hint_reply` / `comment_input_hint_edit`. + public var inputPlaceholder: String { + editingParent || editingCommentId != nil ? "수정할 내용을 입력해주세요..." : "댓글을 남겨보세요..." + } public var menuTargetReply: CommentReplyItem? { guard let id = menuTargetReplyId else { return nil } @@ -112,6 +121,7 @@ public struct CommentReplyFeature { case menuDismissed case replyMenu(id: UUID, action: MenuAction) case parentMenu(MenuAction) + case toastShown } public enum MenuAction: Equatable { @@ -147,9 +157,12 @@ public struct CommentReplyFeature { case createResponse(Result) case updateResponse(Result, commentId: Int) case deleteResponse(Result) + case parentDeleteResponse(Result) case parentLikeResponse(Result) case replyLikeResponse(Result) case parentLikesResponse(Result) + case parentUpdateResponse(Result) + case reportResponse(Result) } public enum DelegateAction: Equatable { @@ -262,21 +275,28 @@ extension CommentReplyFeature { return .send(.delegate(.dismiss)) case .parentLikeTapped: + // Android: 본인 관점은 안내 토스트만, 좋아요 수·상태는 응답을 받은 뒤 반영한다. + if state.parentComment.isMine { + state.toastMessage = "본인이 쓴 관점에는 좋아요를 누를 수 없습니다." + return .none + } analyticsUseCase.track(.uiAction(action: .commentLike, screen: .commentReply)) - let wasLiked = state.parentComment.isLiked - state.parentComment.isLiked.toggle() - state.parentComment.likeCount += state.parentComment.isLiked ? 1 : -1 - return .send(.async(.toggleParentLike(currentlyLiked: wasLiked))) + return .send(.async(.toggleParentLike(currentlyLiked: state.parentComment.isLiked))) case let .replyLikeTapped(id): - analyticsUseCase.track(.uiAction(action: .commentLike, screen: .commentReply)) guard let index = state.replies.firstIndex(where: { $0.id == id }), let commentId = state.replies[index].commentId else { return .none } - let wasLiked = state.replies[index].isLiked - state.replies[index].isLiked.toggle() - state.replies[index].likeCount += state.replies[index].isLiked ? 1 : -1 - return .send(.async(.toggleReplyLike(commentId: commentId, currentlyLiked: wasLiked))) + if state.replies[index].isMine { + state.toastMessage = "본인이 쓴 댓글에는 좋아요를 누를 수 없습니다." + return .none + } + analyticsUseCase.track(.uiAction(action: .commentLike, screen: .commentReply)) + return .send(.async(.toggleReplyLike(commentId: commentId, currentlyLiked: state.replies[index].isLiked))) + + case .toastShown: + state.toastMessage = nil + return .none case .sendTapped: let text = state.replyText.trimmingCharacters(in: .whitespacesAndNewlines) @@ -394,7 +414,7 @@ extension CommentReplyFeature { let cursor = reset ? nil : state.nextCursor return .run { [repository = perspectiveUseCase] send in let result = await Result { - try await repository.fetchLabeledComments(perspectiveId: pid, cursor: cursor, size: 20) + try await repository.fetchLabeledComments(perspectiveId: pid, cursor: cursor, size: 10) } .mapError(CommentError.from) return await send(.inner(.repliesResponse(result, reset: reset))) @@ -477,28 +497,47 @@ extension CommentReplyFeature { case let .reportComment(commentId): let perspectiveId = state.perspectiveId - return .run { [repository = perspectiveUseCase] _ in - try? await repository.reportComment(perspectiveId: perspectiveId, commentId: commentId) + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.reportComment(perspectiveId: perspectiveId, commentId: commentId) + return true + } + .mapError(PerspectiveError.from) + await send(.inner(.reportResponse(result))) } case let .updateParent(content): let perspectiveId = state.perspectiveId return .run { [repository = perspectiveUseCase] send in - try? await repository.updatePerspective(perspectiveId: perspectiveId, content: content) - await send(.async(.fetchParent)) + let result = await Result { + try await repository.updatePerspective(perspectiveId: perspectiveId, content: content) + return true + } + .mapError(PerspectiveError.from) + await send(.inner(.parentUpdateResponse(result))) } case .deleteParent: let perspectiveId = state.perspectiveId return .run { [repository = perspectiveUseCase] send in - try? await repository.deletePerspective(perspectiveId: perspectiveId) - await send(.delegate(.dismiss)) + let result = await Result { + try await repository.deletePerspective(perspectiveId: perspectiveId) + return perspectiveId + } + .mapError(PerspectiveError.from) + await send(.inner(.parentDeleteResponse(result))) } + .cancellable(id: CancelID.mutate, cancelInFlight: false) case .reportParent: let perspectiveId = state.perspectiveId - return .run { [repository = perspectiveUseCase] _ in - try? await repository.reportPerspective(perspectiveId: perspectiveId) + return .run { [repository = perspectiveUseCase] send in + let result = await Result { + try await repository.reportPerspective(perspectiveId: perspectiveId) + return true + } + .mapError(PerspectiveError.from) + await send(.inner(.reportResponse(result))) } } } @@ -519,6 +558,8 @@ extension CommentReplyFeature { state.parentComment = CommentItem(item: perspective, order: 0) case let .failure(error): PickeLogger.error("[CommentReplyFeature] fetchParent failed: \(error.localizedDescription)", category: .ui) + state.loadFailed = true + state.toastMessage = Self.loadFailedMessage } return .none @@ -534,6 +575,10 @@ extension CommentReplyFeature { state.hasNext = page.hasNext case let .failure(error): PickeLogger.error("[CommentReplyFeature] fetchReplies failed: \(error.localizedDescription)", category: .ui) + if reset, state.replies.isEmpty { + state.loadFailed = true + } + state.toastMessage = Self.loadFailedMessage } return .none @@ -543,6 +588,7 @@ extension CommentReplyFeature { return .send(.async(.fetchReplies(reset: true))) case let .failure(error): PickeLogger.error("[CommentReplyFeature] createReply failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage return .none } @@ -553,6 +599,7 @@ extension CommentReplyFeature { return .send(.async(.fetchReplies(reset: true))) case let .failure(error): PickeLogger.error("[CommentReplyFeature] updateReply failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage return .none } @@ -563,9 +610,20 @@ extension CommentReplyFeature { if state.parentComment.replyCount > 0 { state.parentComment.replyCount -= 1 } case let .failure(error): PickeLogger.error("[CommentReplyFeature] deleteReply failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage } return .none + case let .parentDeleteResponse(result): + switch result { + case .success: + return .send(.delegate(.dismiss)) + case let .failure(error): + PickeLogger.error("[CommentReplyFeature] deleteParent failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage + return .none + } + case let .parentLikeResponse(result): switch result { case let .success(payload): @@ -573,6 +631,7 @@ extension CommentReplyFeature { state.parentComment.isLiked = payload.isLiked case let .failure(error): PickeLogger.error("[CommentReplyFeature] toggleParentLike failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage } return .none @@ -585,6 +644,7 @@ extension CommentReplyFeature { } case let .failure(error): PickeLogger.error("[CommentReplyFeature] toggleReplyLike failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage } return .none @@ -596,6 +656,29 @@ extension CommentReplyFeature { PickeLogger.error("[CommentReplyFeature] fetchParentLikes failed: \(error.localizedDescription)", category: .ui) } return .none + + case let .parentUpdateResponse(result): + if case let .failure(error) = result { + PickeLogger.error("[CommentReplyFeature] updateParent failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage + return .none + } + return .send(.async(.fetchParent)) + + case let .reportResponse(result): + switch result { + case .success: + state.toastMessage = "신고가 정상 접수되었습니다." + case .failure(.alreadyReported): + state.toastMessage = "이미 신고한 사용자입니다." + case let .failure(error): + PickeLogger.error("[CommentReplyFeature] report failed: \(error.localizedDescription)", category: .ui) + state.toastMessage = Self.actionFailedMessage + } + return .none } } + + static let loadFailedMessage = "정보를 불러오지 못했어요" + static let actionFailedMessage = "요청을 처리하지 못했어요" } diff --git a/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift index e3b1f020..2ea302e8 100644 --- a/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -50,10 +50,10 @@ public struct CommentReplyView: View { public var body: some View { VStack(spacing: 0) { navigationBar() - replyScroll + content() inputBar } - .screenBackground() + .background(.bgBeige) .contentShape(Rectangle()) .onTapGesture { isReplyFocused = false @@ -64,313 +64,304 @@ public struct CommentReplyView: View { .onChange(of: editingTarget) { _, target in isReplyFocused = target != nil } + .onChange(of: store.toastMessage, initial: true) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) .animation(.easeInOut(duration: 0.18), value: store.menuTargetReplyId) - } -} - -private extension CommentReplyView { - var replyScroll: some View { - ScrollViewReader { proxy in - ScrollView { - if store.isLoadingReplies, store.replies.isEmpty { - CommentReplySkeletonView() - } else { - VStack(spacing: 0) { - parentCommentSection() - replyCountHeader() - replySection() - } - } - } - .scrollIndicators(.hidden) - .scrollDismissesKeyboard(.interactively) - .onChange(of: store.replies.count) { _, _ in - scrollToTargetReply(using: proxy) - } - .onChange(of: store.menuTargetReplyId) { _, id in - guard let id else { return } - withAnimation(.easeInOut(duration: 0.18)) { - proxy.scrollTo("reply-menu-\(id)", anchor: nil) - } - } - .onChange(of: store.parentMenuOpen) { _, isOpen in - guard isOpen else { return } - withAnimation(.easeInOut(duration: 0.18)) { - proxy.scrollTo("parent-menu", anchor: nil) - } - } - } - } - - /// 답글 "…" 메뉴 (내 글: 수정/삭제, 남 글: 신고) — pill 형태. - @ViewBuilder - func replyMenu(for reply: CommentReplyItem) -> some View { - VStack(alignment: .trailing, spacing: 8) { - if reply.isMine { - PickeActionPill(title: "수정", systemImage: "pencil") { - guard reply.commentId != nil else { return } - send(.replyMenu(id: reply.id, action: .edit)) - isReplyFocused = true - } - PickeActionPill(title: "삭제", systemImage: "trash") { send(.replyMenu(id: reply.id, action: .delete)) } - } else { - PickeActionPill(title: "신고", systemImage: "light.beacon.max.fill") { send(.replyMenu( - id: reply.id, - action: .report - )) } - } - } - } - - /// 부모(관점) "…" 메뉴 (내 글: 수정/삭제, 남 글: 신고). - @ViewBuilder - func parentMenuView() -> some View { - VStack(alignment: .trailing, spacing: 8) { - if store.parentComment.isMine { - PickeActionPill(title: "수정", systemImage: "pencil") { - send(.parentMenu(.edit)) - isReplyFocused = true - } - PickeActionPill(title: "삭제", systemImage: "trash") { send(.parentMenu(.delete)) } - } else { - PickeActionPill(title: "신고", systemImage: "light.beacon.max.fill") { send(.parentMenu(.report)) } - } - } + .animation(.easeInOut(duration: 0.18), value: store.parentMenuOpen) } } // MARK: - Navigation private extension CommentReplyView { - @ViewBuilder + /// Android PickeTopAppBar — "댓글" 가운데, 16 SemiBold textDefault, 배경 backgroundBeige. func navigationBar() -> some View { PickeNavigationBar( onBack: { send(.backButtonTapped) }, - leadingTitle: "댓글" + centerTitle: "댓글" ) - .foregroundStyle(.gray500) - // Figma: 앱바는 beige200 위에 투명 배치, 하단 구분선 없음 - .background(.beige200) + .foregroundStyle(.textDefault) + .background(.bgBeige) } } // MARK: - Content private extension CommentReplyView { + /// Android CommentScreen — 로딩 중 스켈레톤, 실패 시 PickeLoadFailedSection, + /// 그 밖에는 원본 관점과 "답글 N개" 헤더를 고정하고 아래 답글 목록만 스크롤한다. @ViewBuilder - func parentCommentSection() -> some View { - // Figma: 원본 댓글은 beige50 풀폭 행, 상·하단 구분선(beige600) - VStack(spacing: 0) { - commentCard( + func content() -> some View { + if store.loadFailed { + PickeLoadFailedSection() + .padding(16) + .frame(maxHeight: .infinity, alignment: .top) + } else if store.isLoadingReplies, store.replies.isEmpty { + ScrollView { + CommentReplySkeletonView() + } + .scrollIndicators(.hidden) + .frame(maxHeight: .infinity) + } else { + VStack(spacing: 0) { + parentCard + replyCountHeader + replyScroll + } + } + } + + var parentCard: some View { + commentCard( + CommentCardContent( author: store.parentComment.isMine ? "나" : store.parentComment.author, imageURL: store.parentComment.authorImageURL, timeAgo: store.parentComment.timeAgo, - option: store.parentComment.option, + stance: store.parentComment.optionLabel ?? store.parentComment.option.label, content: store.parentComment.content, - replyCount: store.parentComment.replyCount, likeCount: store.parentComment.likeCount, isLiked: store.parentComment.isLiked, - showsReplyCount: true, - moreAction: { send(.parentMenu(.more)) }, - likeAction: { send(.parentLikeTapped) } - ) - .topDivider(.beige600) - - if store.parentMenuOpen { - parentMenuView() - .padding(.trailing, 16) - .padding(.vertical, 12) - .frame(maxWidth: .infinity, alignment: .trailing) - .background(.beige50) - .bottomDivider(.beige600) - .id("parent-menu") - } - } + replyCount: store.parentComment.replyCount + ), + menuItems: parentMenuItems, + isMenuOpen: store.parentMenuOpen, + opensUpward: false, + onMore: { send(.parentMenu(.more)) }, + onLike: { send(.parentLikeTapped) } + ) + .zIndex(1) } - @ViewBuilder - func replyCountHeader() -> some View { - // Figma: "답글 N개" 헤더 — beige200 배경, 하단 구분선, gray800 SemiBold 13 + /// Android CommentHeaderSection — 가로 16·세로 12, 13 SemiBold gray600. + /// 배경 F9F8F6 은 토큰이 없어 가장 가까운 surfaceBeigeSubtle(F9F7F2)을 쓴다. + var replyCountHeader: some View { Text("답글 \(store.replies.count)개") .pretendardFont(.semiBold13) - .foregroundStyle(.gray800) + .foregroundStyle(.gray600) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 18) - .frame(height: 42) - .background(.beige200) - .bottomDivider(.beige600) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.surfaceBeigeSubtle) } - @ViewBuilder - func replySection() -> some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(store.replies) { reply in - VStack(spacing: 0) { - commentCard( - author: reply.isMine ? "나" : reply.author, - imageURL: reply.authorImageURL, - timeAgo: reply.timeAgo, - option: reply.option, - content: reply.content, - replyCount: nil, - likeCount: reply.likeCount, - isLiked: reply.isLiked, - showsReplyCount: false, - moreAction: { send(.replyMenu(id: reply.id, action: .more)) }, - likeAction: { send(.replyLikeTapped(reply.id)) } - ) - - if store.menuTargetReplyId == reply.id { - replyMenu(for: reply) - .padding(.trailing, 16) - .padding(.vertical, 12) - .frame(maxWidth: .infinity, alignment: .trailing) - .background(.beige50) - .bottomDivider(.beige600) - .id("reply-menu-\(reply.id)") + var replyScroll: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(store.replies) { reply in + commentCard( + CommentCardContent( + author: reply.isMine ? "나" : reply.author, + imageURL: reply.authorImageURL, + timeAgo: reply.timeAgo, + stance: reply.stance.isEmpty ? reply.option.label : reply.stance, + content: reply.content, + likeCount: reply.likeCount, + isLiked: reply.isLiked, + replyCount: nil + ), + menuItems: replyMenuItems(for: reply), + isMenuOpen: store.menuTargetReplyId == reply.id, + opensUpward: reply.id == store.replies.last?.id && store.replies.count > 1, + onMore: { send(.replyMenu(id: reply.id, action: .more)) }, + onLike: { send(.replyLikeTapped(reply.id)) } + ) + .zIndex(store.menuTargetReplyId == reply.id ? 1 : 0) + .id(reply.id) } } - .id(reply.id) + } + .scrollIndicators(.hidden) + .scrollDismissesKeyboard(.interactively) + .onChange(of: store.replies.count) { _, _ in + scrollToTargetReply(using: proxy) } } } +} - @ViewBuilder +// MARK: - Card + +/// Android PickeCommentCard 에 넘기는 값 묶음. +private struct CommentCardContent { + let author: String + let imageURL: String? + let timeAgo: String + let stance: String + let content: String + let likeCount: Int + let isLiked: Bool + let replyCount: Int? +} + +private extension CommentReplyView { + /// Android PickeCommentCard(CommentItemCard) — surfaceBeigeDefault, 가로 16·세로 12, 간격 8, 아래 1 borderBeigeDefault. + /// 헤더 [아바타 36][닉네임 12 SemiBold / 시간 10 SemiBold][배지], 본문 13 Regular textBody 전체, + /// 액션 [좋아요][답글 수(원본만)] … [ic_more 24]. "…" 메뉴는 버튼 위로 펼친다. func commentCard( - author: String, - imageURL: String?, - timeAgo: String, - option: CommentOption, - content: String, - replyCount: Int?, - likeCount: Int, - isLiked: Bool, - showsReplyCount: Bool, - moreAction: (() -> Void)? = nil, - likeAction: @escaping () -> Void + _ card: CommentCardContent, + menuItems: [PickeMoreMenuItem], + isMenuOpen: Bool, + opensUpward: Bool, + onMore: @escaping () -> Void, + onLike: @escaping () -> Void ) -> some View { - // Figma: 댓글/답글은 beige50 풀폭 행 + 하단 구분선(beige600), 내부 카드 박스 없음 VStack(alignment: .leading, spacing: 8) { - commentHeader( - author: author, - imageURL: imageURL, - timeAgo: timeAgo, - option: option, - moreAction: moreAction - ) - - Text(content) - .pretendardFont(.regular13) - .foregroundStyle(.gray400) - .lineSpacing(13 * 0.4) + cardHeader(card) + Text(card.content) + .pretendardFont(.regular13, lineSpacingMultiple: 1.4) + .foregroundStyle(.textBody) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 2) - - HStack(spacing: 11) { - Spacer() - - if showsReplyCount, let replyCount { - actionLabel(systemName: "message", text: "\(replyCount)") - } - - Button(action: likeAction) { - HStack(spacing: 2) { - Image(asset: .heartPlus) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - Text(likeCount.decimalFormatted) - .pretendardFont(.labelSmall) - } - .foregroundStyle(isLiked ? .primary500 : .gray300) - } - .buttonStyle(.plain) - } - .foregroundStyle(.gray300) + cardActions( + card, + menuItems: isMenuOpen ? menuItems : [], + opensUpward: opensUpward, + onMore: onMore, + onLike: onLike + ) } .padding(.horizontal, 16) .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) - .background(.beige50) - .bottomDivider(.beige600) + // 구분선은 내용 아래에 그려 펼친 메뉴를 가리지 않게 한다. + .background(alignment: .bottom) { + ZStack(alignment: .bottom) { + Color.surfaceBeigeDefault + PickeDivider(.borderBeigeDefault) + } + } } @ViewBuilder - func commentHeader( - author: String, - imageURL: String?, - timeAgo: String, - option: CommentOption, - moreAction: (() -> Void)? - ) -> some View { + func cardHeader(_ card: CommentCardContent) -> some View { HStack(spacing: 8) { PickeAvatarView( - imageURL: imageURL, - fallback: author, + imageURL: card.imageURL, + fallback: card.author, size: 36 ) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(author) - .pretendardFont(.semiBold12) - .foregroundStyle(.gray500) - .lineLimit(1) - - optionBadge(label: option.label, option: option) - } - - // 시간 — caption/sm/semibold Pretendard SemiBold 10, gray300 - Text(timeAgo) + VStack(alignment: .leading, spacing: 0) { + Text(card.author) + .pretendardFont(.semiBold12) + .foregroundStyle(.textDefault) + .lineLimit(1) + .truncationMode(.tail) + Text(card.timeAgo) .pretendardFont(.labelXSmall) - .foregroundStyle(.gray300) + .foregroundStyle(.textMuted) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + Text(card.stance) + .pickeBadge() + } + } + + @ViewBuilder + func cardActions( + _ card: CommentCardContent, + menuItems: [PickeMoreMenuItem], + opensUpward: Bool, + onMore: @escaping () -> Void, + onLike: @escaping () -> Void + ) -> some View { + HStack(spacing: 8) { + Button(action: onLike) { + iconCount( + .heartPlus, + count: card.likeCount + ) + .foregroundStyle(card.isLiked ? .primary500 : .textMuted) + } + .buttonStyle(.plain) + .accessibilityLabel("좋아요") + + if let replyCount = card.replyCount { + iconCount( + .commentMessage, + count: replyCount + ) + .foregroundStyle(.textMuted) + .accessibilityLabel("답글") } - Spacer() + Spacer(minLength: 0) - if let moreAction { - Button(action: moreAction) { - Image(systemName: "ellipsis") - .pretendardFont(family: .Regular, size: 18) - .foregroundStyle(.gray300) - .frame(width: 24, height: 24) - .contentShape(Rectangle()) + Button(action: onMore) { + Image(asset: .more) + .renderingMode(.template) + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle(.textMuted) + } + .buttonStyle(.plain) + .accessibilityLabel("더보기") + .overlay(alignment: opensUpward ? .bottomTrailing : .topTrailing) { + // Android DropdownMenu — "…" 버튼 바로 밑에서 펼치고, 아래 자리가 없으면(마지막 항목) 위로 펼친다. + if !menuItems.isEmpty { + PickeMoreMenuView(items: menuItems) + .fixedSize() + .offset(y: opensUpward ? -24 : 24) + .transition(.opacity) } - .buttonStyle(.plain) - .accessibilityLabel("더보기") } } } - func optionBadge( - label: String, - option: CommentOption + /// Android PickeIconCount — 아이콘 16 + 12 Medium, 간격 2. + func iconCount( + _ icon: ImageAsset, + count: Int ) -> some View { - Text(label) - .pretendardFont(.labelSmall) - .foregroundStyle(option == .a ? .primary500 : .beige50) - .padding(.horizontal, option == .a ? 4 : 6) - .padding(.vertical, 2) - .roundedBackground(option == .a ? .beige600 : .primary500) - } - - func actionLabel( - systemName: String, - text: String - ) -> some View { - // Figma like/more 칩: 아이콘 16 + 텍스트(Medium 12, gray300), 내부 gap 2, padding px6/py4 HStack(spacing: 2) { - Image(systemName: systemName) - .font(.system(size: 14, weight: .medium)) + Image(asset: icon) + .renderingMode(.template) + .resizable() + .scaledToFit() .frame(width: 16, height: 16) - - Text(text) + Text(count.decimalFormatted) .pretendardFont(.labelSmall) } } } +// MARK: - Menu + +private extension CommentReplyView { + /// Android commentMenuItems — 내 글 [삭제, 수정], 남의 글 [신고]. + var parentMenuItems: [PickeMoreMenuItem] { + if store.parentComment.isMine { + return [ + .delete { send(.parentMenu(.delete)) }, + .edit { + send(.parentMenu(.edit)) + isReplyFocused = true + }, + ] + } + return [.report { send(.parentMenu(.report)) }] + } + + func replyMenuItems(for reply: CommentReplyItem) -> [PickeMoreMenuItem] { + if reply.isMine { + return [ + .delete { send(.replyMenu(id: reply.id, action: .delete)) }, + .edit { + guard reply.commentId != nil else { return } + send(.replyMenu(id: reply.id, action: .edit)) + isReplyFocused = true + }, + ] + } + return [.report { send(.replyMenu(id: reply.id, action: .report)) }] + } +} + // MARK: - Input private extension CommentReplyView { @@ -378,9 +369,8 @@ private extension CommentReplyView { PickeCommentInputBar( text: $store.replyText, focus: $isReplyFocused, - placeholder: "자유롭게 의견을 써주세요.", + placeholder: store.inputPlaceholder, isSendEnabled: store.isSendEnabled, - characterLimit: store.replyCharacterLimit, onSend: { isReplyFocused = false send(.sendTapped) diff --git a/Projects/Feature/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift b/Projects/Feature/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift index fc22a6c9..3c4afca4 100644 --- a/Projects/Feature/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift +++ b/Projects/Feature/Chat/Sources/CommentReply/View/Components/CommentReplySkeletonView.swift @@ -9,60 +9,52 @@ import SwiftUI import PickeDesignKit +/// Android `CommentSkeleton` — 원본 카드(본문 2줄) → "답글" 헤더 → 카드 4개(각각 아래 구분선). struct CommentReplySkeletonView: View { - var replyCount: Int = 3 - var body: some View { VStack(spacing: 0) { - parentCard() - .padding(12) - .background(.beige50) - .bottomDivider(.beige600) - - VStack(alignment: .leading, spacing: 8) { - SkeletonView(.round(cornerRadius: 4)) - .frame(width: 64, height: 14) - .padding(.horizontal, 12) - .padding(.top, 12) - - ForEach(0 ..< replyCount, id: \.self) { _ in - card() - .padding(.horizontal, 12) - } + card(showsSecondLine: true) + SkeletonView.line(width: 70, height: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.surfaceBeigeSubtle) + ForEach(0 ..< 4, id: \.self) { _ in + card(showsSecondLine: false) + .overlay(alignment: .bottom) { + PickeDivider(.borderBeigeDefault) + } } - .padding(.bottom, 24) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.beige50) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - @ViewBuilder - private func parentCard() -> some View { - card() + .allowsHitTesting(false) } @ViewBuilder - private func card() -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 8) { - SkeletonView(.round(cornerRadius: 18)) - .frame(width: 36, height: 36) + private func card(showsSecondLine: Bool) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + SkeletonView.beige(.circle) + .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 4) { - SkeletonView(.round(cornerRadius: 4)).frame(width: 90, height: 14) - SkeletonView(.round(cornerRadius: 4)).frame(width: 56, height: 16) + SkeletonView.line(width: 48, height: 16) + SkeletonView.line(width: 40, height: 12) } - Spacer() + Spacer(minLength: 0) } - SkeletonView(.round(cornerRadius: 4)).frame(maxWidth: .infinity).frame(height: 12) - SkeletonView(.round(cornerRadius: 4)).frame(width: 220, height: 12) - HStack { - Spacer() - SkeletonView(.round(cornerRadius: 4)).frame(width: 44, height: 14) + SkeletonView.line(width: 70, height: 20) + .padding(.top, 8) + SkeletonView.line(width: 260, height: 16) + .padding(.top, 12) + if showsSecondLine { + SkeletonView.line(width: 180, height: 16) + .padding(.top, 4) } + SkeletonView.line(width: 36, height: 16) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.top, 12) } - .padding(12) + .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .pickeCard(.beige50, border: .beige600) + .background(.surfaceBeigeDefault) } } diff --git a/Projects/Feature/Chat/Sources/Curation/Reducer/CurationFeature.swift b/Projects/Feature/Chat/Sources/Curation/Reducer/CurationFeature.swift index 9753caf0..2389a869 100644 --- a/Projects/Feature/Chat/Sources/Curation/Reducer/CurationFeature.swift +++ b/Projects/Feature/Chat/Sources/Curation/Reducer/CurationFeature.swift @@ -27,6 +27,8 @@ public struct CurationFeature { public var viewState: ViewState = .loaded public var battles: [RecommendedBattle] = [] + /// Android `RecommendViewModel` 조회 실패 토스트 — 뷰가 띄운 뒤 `toastShown`으로 비운다. + public var toastMessage: String? public init(battleId: Int = 0) { self.battleId = battleId @@ -48,6 +50,7 @@ public struct CurationFeature { case battleTapped(battleId: Int) case adNativeClicked case serverAdClicked(network: String) + case toastShown } public enum AsyncAction: Equatable { @@ -92,10 +95,14 @@ public struct CurationFeature { extension CurationFeature { private func handleViewAction( - state _: inout State, + state: inout State, action: View ) -> Effect { switch action { + case .toastShown: + state.toastMessage = nil + return .none + case .onAppear: analyticsUseCase.track(.screenView(screen: .curation, referrer: nil)) return .send(.async(.fetchRecommendations)) @@ -157,6 +164,7 @@ extension CurationFeature { case let .failure(error): PickeLogger.error("[CurationFeature] recommendations failed: \(error.localizedDescription)", category: .ui) state.battles = [] + state.toastMessage = "정보를 불러오지 못했어요" } return .none } diff --git a/Projects/Feature/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift b/Projects/Feature/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift index 2bc50c05..fb0e9054 100644 --- a/Projects/Feature/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift +++ b/Projects/Feature/Chat/Sources/Curation/View/Components/CurationSkeletonView.swift @@ -5,58 +5,91 @@ // Created by Picke on 10/11/26. // -import SwiftUI - import PickeDesignKit +import SwiftUI +/// Android `RecommendListSkeleton` — 카드 4개, 좌우·위 16, 아래 80, 간격 16. struct CurationSkeletonView: View { - var count: Int = 4 - var body: some View { VStack(spacing: 16) { - ForEach(0 ..< count, id: \.self) { _ in + ForEach(0 ..< 4, id: \.self) { _ in card() } } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 80) + .frame(maxHeight: .infinity, alignment: .top) + .allowsHitTesting(false) } @ViewBuilder private func card() -> some View { - VStack(alignment: .leading, spacing: 16) { - // meta - HStack(spacing: 10) { - SkeletonView(.round(cornerRadius: .radiusDefault)).frame(width: 44, height: 18) - Spacer() - SkeletonView(.round(cornerRadius: 4)).frame(width: 36, height: 12) - SkeletonView(.round(cornerRadius: 4)).frame(width: 36, height: 12) - } - VStack(alignment: .leading, spacing: 4) { - SkeletonView(.round(cornerRadius: 4)).frame(maxWidth: .infinity).frame(height: 14) - SkeletonView(.round(cornerRadius: 4)).frame(width: 220, height: 12) + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + line(width: 50, height: 18) + Spacer(minLength: 0) + line(width: 70, height: 14) } - // versus - HStack(spacing: 8) { - optionPlaceholder() - SkeletonView(.round(cornerRadius: 12)).frame(width: 24, height: 24) - optionPlaceholder() + line(width: 200, height: 18) + .padding(.top, 12) + line(width: 240, height: 14) + .padding(.top, 6) + HStack(spacing: 2) { + opinionBox() + block(.circle) + .frame(width: 40, height: 40) + opinionBox() } + .padding(.top, 8) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) - .pickeCard(.beige50, border: .beige600) + .roundedBorder( + .borderBeigeDefault, + lineWidth: 1, + radius: .radiusDefault, + insideBorder: true + ) } @ViewBuilder - private func optionPlaceholder() -> some View { + private func opinionBox() -> some View { HStack(spacing: 4) { - SkeletonView(.round(cornerRadius: 20)).frame(width: 40, height: 40) - VStack(spacing: 2) { - SkeletonView(.round(cornerRadius: 4)).frame(width: 44, height: 11) - SkeletonView(.round(cornerRadius: 4)).frame(width: 30, height: 10) + block(.circle) + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 2) { + line(width: 50, height: 14) + line(width: 36, height: 12) } + Spacer(minLength: 0) } - .frame(maxWidth: .infinity) .padding(8) - .pickeCard(.beige300, border: .beige600) + .frame(maxWidth: .infinity) + .roundedBorder( + .borderBeigeDisabled, + lineWidth: 1, + radius: .radiusDefault, + insideBorder: true + ) + } + + /// Android `PickeSkeletonLine` — radius 4, 좁은 자리에서는 줄어든다. + private func line( + width: CGFloat, + height: CGFloat + ) -> some View { + block(.round(cornerRadius: 4)) + .frame(maxWidth: width) + .frame(height: height) + } + + /// Android `Modifier.shimmer()` 기본 색. + private func block(_ shape: SkeletonShape) -> some View { + SkeletonView( + shape, + base: .surfaceBeigeStrong, + highlight: .surfaceBeigeDefault + ) } } diff --git a/Projects/Feature/Chat/Sources/Curation/View/CurationView.swift b/Projects/Feature/Chat/Sources/Curation/View/CurationView.swift index 6b78e4ff..399aef7c 100644 --- a/Projects/Feature/Chat/Sources/Curation/View/CurationView.swift +++ b/Projects/Feature/Chat/Sources/Curation/View/CurationView.swift @@ -26,41 +26,29 @@ public struct CurationView: View { VStack(spacing: 0) { header() GeometryReader { viewport in - ScrollView(showsIndicators: false) { - VStack(spacing: 16) { - // 큐레이션 리스트 최상단 — 탐색과 동일한 서버 피드 광고. - FeedNativeAdView( - insets: EdgeInsets(top: 0, leading: 0, bottom: 4, trailing: 0), - viewport: viewport.frame(in: .global), - onServerAdClick: { send(.serverAdClicked(network: $0)) } - ) - - if store.viewState == .loading, store.battles.isEmpty { - CurationSkeletonView() - } else if store.battles.isEmpty { - emptyState() - } else { - ForEach(store.battles) { battle in - battleCard(battle) - } - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 16) + if store.viewState == .loading, store.battles.isEmpty { + CurationSkeletonView() + } else { + battleList(viewport: viewport) } } } - .screenBackground() + .background(.surfaceBeigeDefault) .navigationBarHidden(true) .hidesSystemBars() .onAppear { send(.onAppear) } + .onChange(of: store.toastMessage, initial: true) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } } } // MARK: - Header private extension CurationView { + /// Android `PickeTopAppBar` — 배경 surfaceBeigeDefault, 구분선 없음, 닫기 `ic_x` 14. @ViewBuilder func header() -> some View { PickeNavigationBar( @@ -68,146 +56,122 @@ private extension CurationView { centerTitle: "더 흥미로운 배틀도 있어요!" ) { Button { send(.closeButtonTapped) } label: { - Image(systemName: "xmark") - .font(.system(size: 16, weight: .regular)) + Image(asset: .close) + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) .frame(width: 24, height: 24) } .buttonStyle(.plain) + .accessibilityLabel("닫기") } - .foregroundStyle(.neutral900) - .background(.beige200) - .bottomDivider(.beige600) + .foregroundStyle(.textDefault) + .background(.surfaceBeigeDefault) } } -// MARK: - Empty +// MARK: - List private extension CurationView { + /// Android `RecommendScreen` LazyColumn — 좌우·위 16, 아래 80, 간격 16. 목록이 비어도 광고 자리만 남는다. @ViewBuilder - func emptyState() -> some View { - VStack(spacing: 8) { - Image(systemName: "tray") - .font(.system(size: 28, weight: .regular)) - .foregroundStyle(.neutral300) - Text("추천할 배틀이 없어요") - .pretendardFont(.labelMedium) - .foregroundStyle(.neutral300) + func battleList(viewport: GeometryProxy) -> some View { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 16) { + // 큐레이션 리스트 최상단 — 탐색과 동일한 서버 피드 광고. + FeedNativeAdView( + insets: EdgeInsets(top: 0, leading: 0, bottom: 4, trailing: 0), + viewport: viewport.frame(in: .global), + onServerAdClick: { send(.serverAdClicked(network: $0)) } + ) + ForEach(store.battles) { battle in + battleCard(battle) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 80) } - .frame(maxWidth: .infinity) - .padding(.top, 80) } } // MARK: - Battle Card private extension CurationView { + /// Android `RecommendItemCard` — 안쪽 12, radius 2, 면 surfaceBeigeDefault·테두리 borderBeigeDefault. @ViewBuilder func battleCard(_ battle: RecommendedBattle) -> some View { Button { send(.battleTapped(battleId: battle.battleId)) } label: { - VStack(alignment: .leading, spacing: 16) { - cardMeta(battle) + VStack(alignment: .leading, spacing: 0) { + cardMetaRow(battle) + Text(battle.title) + .pretendardFont(.headingSmall) + .foregroundStyle(.textDefault) + .lineLimit(1) + .truncationMode(.tail) + .padding(.top, 12) + Text(battle.summary) + .pretendardFont(.labelSmall) + .foregroundStyle(.gray400) + .lineLimit(2) + .truncationMode(.tail) + .padding(.top, 6) cardVersus(battle) + .padding(.top, 8) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) - .pickeCard(.beige50, border: .beige600) + .pickeCard( + .surfaceBeigeDefault, + border: .borderBeigeDefault, + insideBorder: true + ) + .contentShape(Rectangle()) } .buttonStyle(.plain) } @ViewBuilder - func cardMeta(_ battle: RecommendedBattle) -> some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 10) { - if let tag = battle.tags.first { - Text("#\(tag.name)") - .pickeBadge(.filled, size: .tag) - } - - Spacer() - - HStack(spacing: 2) { - Image(systemName: "clock") - .font(.system(size: 11, weight: .regular)) - Text(battle.audioDuration.roundedMinuteText) - .pretendardFont(.labelSmall) - } - .foregroundStyle(.neutral300) - - HStack(spacing: 2) { - Image(systemName: "eye") - .font(.system(size: 11, weight: .regular)) - Text("\(battle.viewCount)") - .pretendardFont(.labelSmall) - } - .foregroundStyle(.neutral300) - } - - VStack(alignment: .leading, spacing: 4) { - Text(battle.title) - .pretendardFont(.headingSmall) - .foregroundStyle(.neutral500) - .lineSpacing(14 * 0.3) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - - if !battle.summary.isEmpty { - Text(battle.summary) - .pretendardFont(.labelSmall) - .foregroundStyle(.neutral200) - .lineSpacing(12 * 0.4) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } + func cardMetaRow(_ battle: RecommendedBattle) -> some View { + HStack(spacing: 0) { + Text(battle.tagLabel) + .pretendardFont(.labelSmall) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .roundedBackground(.borderBeigeDefault) + Spacer(minLength: 0) + HStack(spacing: 8) { + MetaLabelView( + asset: .clock, + text: battle.durationLabel, + spacing: 4 + ) + MetaLabelView( + asset: .eye, + text: "\(battle.viewCount)", + spacing: 4 + ) } } } @ViewBuilder func cardVersus(_ battle: RecommendedBattle) -> some View { - // QA-40: VS 뱃지를 양쪽 옵션 버튼의 세로 중앙에 두고, 두 옵션은 동일 너비로 통일한다. - HStack(spacing: 8) { - optionButton(battle.options[safe: 0]) - versusBadge() - optionButton(battle.options[safe: 1]) + HStack(spacing: 2) { + opinionBox(battle.options[safe: 0]) + BattleVersusBadge() + opinionBox(battle.options[safe: 1]) } - .frame(maxWidth: .infinity) } - @ViewBuilder - func optionButton(_ option: RecommendedBattleOption?) -> some View { - // QA-40/42: 두 옵션 버튼을 좌측 정렬 + 동일 너비로 통일해 위치가 들쭉날쭉하지 않게 한다. - HStack(spacing: 4) { - PickeAvatarView( - imageURL: option?.imageUrl, - fallback: option?.representative ?? "", - size: 40 - ) - - // 철학자 이름 / 대표 의견은 모두 좌측 정렬. - VStack(alignment: .leading, spacing: 2) { - Text(option?.title ?? "") - .pretendardFont(.semiBold11) - .foregroundStyle(.neutral500) - .frame(maxWidth: .infinity, alignment: .leading) - Text(option?.representative ?? "") - .pretendardFont(.regular10) - .foregroundStyle(.neutral300) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - .pickeCard(.beige300, border: .beige600) - } - - @ViewBuilder - func versusBadge() -> some View { - Text("VS") - .pretendardFont(.bold8) - .foregroundStyle(.neutral900) - .frame(width: 24, height: 24) - .background(.secondary200, in: Circle()) + /// Android 입장(`stance`) + 대표(`representative`) + 이미지. + func opinionBox(_ option: RecommendedBattleOption?) -> some View { + BattleOpinionBox( + opinion: option?.stance ?? "", + name: option?.representative ?? "", + imageURL: option?.imageUrl.flatMap(URL.init(string:)) + ) } } diff --git a/Projects/Feature/Chat/Tests/Sources/CommentParityTests.swift b/Projects/Feature/Chat/Tests/Sources/CommentParityTests.swift new file mode 100644 index 00000000..b75234e2 --- /dev/null +++ b/Projects/Feature/Chat/Tests/Sources/CommentParityTests.swift @@ -0,0 +1,128 @@ +// +// CommentParityTests.swift +// ChatTests +// +// Created by Picke on 11/10/26. +// + +import BattleDomainInterface +@testable import Chat +import CommentDomainInterface +import ComposableArchitecture +import Foundation +import PerspectiveDomainInterface +import Testing + +/// Android `PerspectiveScreen`/`PerspectiveViewModel` 과 맞춘 관점 목록 동작. +@MainActor +struct CommentParityTests { + private func comment( + id: Int, + isMine: Bool = false + ) -> CommentItem { + CommentItem( + perspectiveId: id, + author: "작성자\(id)", + timeAgo: "1분 전", + option: .a, + content: "내용", + replyCount: 0, + likeCount: 3, + isMine: isMine, + createdOrder: id + ) + } + + @Test + func ownPerspectiveLikeShowsToastWithoutRequest() async { + var state = CommentFeature.State(battleId: 1) + let mine = comment(id: 7, isMine: true) + state.comments = [mine] + let store = TestStore(initialState: state) { CommentFeature() } + + await store.send(.view(.commentRow(id: mine.id, action: .like))) { + $0.toastMessage = "본인이 쓴 관점에는 좋아요를 누를 수 없습니다." + } + await store.send(.view(.toastShown)) { + $0.toastMessage = nil + } + } + + @Test + func lastCommentDoesNotLoadMoreWithoutNextPage() async { + var state = CommentFeature.State(battleId: 1) + state.comments = [comment(id: 1)] + state.hasNext = false + state.nextCursor = "c" + let store = TestStore(initialState: state) { CommentFeature() } + + await store.send(.view(.lastCommentAppeared)) + } + + @Test + func lastCommentDoesNotLoadMoreWhileLoading() async { + var state = CommentFeature.State(battleId: 1) + state.comments = [comment(id: 1)] + state.hasNext = true + state.nextCursor = "c" + state.isLoadingComments = true + let store = TestStore(initialState: state) { CommentFeature() } + + await store.send(.view(.lastCommentAppeared)) + } + + @Test + func nextPageAppendsWithoutDuplicatesAndStopsOnEmptyPage() async { + var state = CommentFeature.State(battleId: 1) + state.comments = [comment(id: 1)] + let store = TestStore(initialState: state) { CommentFeature() } + store.exhaustivity = .off + + let page = BattlePerspectivePage(items: [], nextCursor: "next", hasNext: true) + await store.send(.inner(.perspectivesResponse(.success(page), reset: false))) { + $0.hasNext = false + $0.nextCursor = "next" + } + #expect(store.state.comments.count == 1) + } + + @Test + func reportResultsShowAndroidToasts() async { + let store = TestStore(initialState: CommentFeature.State(battleId: 1)) { CommentFeature() } + + await store.send(.inner(.reportResponse(.success(true)))) { + $0.toastMessage = "신고가 정상 접수되었습니다." + } + await store.send(.inner(.reportResponse(.failure(.alreadyReported)))) { + $0.toastMessage = "이미 신고한 사용자입니다." + } + await store.send(.inner(.reportResponse(.failure(.serverError(500))))) { + $0.toastMessage = "요청을 처리하지 못했어요" + } + } + + @Test + func firstVoteStatsFailureShowsFailedSectionAndToast() async { + let store = TestStore(initialState: CommentFeature.State(battleId: 1)) { CommentFeature() } + + await store.send(.inner(.voteStatsResponse(.failure(.noData)))) { + $0.hasLoadedHeader = true + $0.headerLoadFailed = true + $0.toastMessage = "정보를 불러오지 못했어요" + } + } + + @Test + func labelsFollowAndroidStrings() { + var state = CommentFeature.State(battleId: 1) + #expect(state.changeBadgeTitle == "생각이 동일해요") + #expect(state.navigationTitle == "관점 남기기") + #expect(state.inputPlaceholder == "의견을 남겨보세요...") + state.editingPerspectiveId = 3 + #expect(state.inputPlaceholder == "수정할 내용을 입력해주세요...") + state.selectedFilter = .optionA + #expect(state.emptyMessage == "아직 작성된 \"\(state.voteSummary.optionA.title)\" 관점이 없습니다") + #expect(VoteOptionSummary(label: "A", title: "", representative: "", percentage: 0.59).rateLabel == "59%") + #expect(VoteOptionSummary(label: "A", title: "", representative: "", percentage: 0.595).rateLabel == "59.5%") + } +} diff --git a/Projects/Feature/Chat/Tests/Sources/CommentReplyParityTests.swift b/Projects/Feature/Chat/Tests/Sources/CommentReplyParityTests.swift new file mode 100644 index 00000000..fefe09b6 --- /dev/null +++ b/Projects/Feature/Chat/Tests/Sources/CommentReplyParityTests.swift @@ -0,0 +1,94 @@ +// +// CommentReplyParityTests.swift +// ChatTests +// +// Created by Picke on 11/10/26. +// + +@testable import Chat +import CommentDomainInterface +import ComposableArchitecture +import Foundation +import PerspectiveDomainInterface +import Testing + +/// Android `CommentScreen`/`CommentViewModel` 과 맞춘 댓글 화면 동작. +@MainActor +struct CommentReplyParityTests { + private func state(parentIsMine: Bool = false) -> CommentReplyFeature.State { + CommentReplyFeature.State( + perspectiveId: 1, + parentComment: CommentItem( + perspectiveId: 1, + author: "원작성자", + timeAgo: "1분 전", + option: .a, + content: "원본", + replyCount: 1, + likeCount: 2, + isMine: parentIsMine, + createdOrder: 0 + ) + ) + } + + @Test + func ownParentLikeShowsPerspectiveToast() async { + let store = TestStore(initialState: state(parentIsMine: true)) { CommentReplyFeature() } + + await store.send(.view(.parentLikeTapped)) { + $0.toastMessage = "본인이 쓴 관점에는 좋아요를 누를 수 없습니다." + } + } + + @Test + func ownReplyLikeShowsReplyToast() async { + var initial = state() + let reply = CommentReplyItem( + commentId: 9, + author: "나", + timeAgo: "방금 전", + option: .a, + content: "답글", + likeCount: 0, + isMine: true, + createdOrder: 0 + ) + initial.replies = [reply] + let store = TestStore(initialState: initial) { CommentReplyFeature() } + + await store.send(.view(.replyLikeTapped(reply.id))) { + $0.toastMessage = "본인이 쓴 댓글에는 좋아요를 누를 수 없습니다." + } + } + + @Test + func parentFailureShowsFailedSectionAndToast() async { + let store = TestStore(initialState: state()) { CommentReplyFeature() } + + await store.send(.inner(.parentResponse(.failure(.noData)))) { + $0.loadFailed = true + $0.toastMessage = "정보를 불러오지 못했어요" + } + } + + @Test + func reportResultsShowAndroidToasts() async { + let store = TestStore(initialState: state()) { CommentReplyFeature() } + + await store.send(.inner(.reportResponse(.success(true)))) { + $0.toastMessage = "신고가 정상 접수되었습니다." + } + await store.send(.inner(.reportResponse(.failure(.alreadyReported)))) { + $0.toastMessage = "이미 신고한 사용자입니다." + } + } + + @Test + func inputPlaceholderFollowsAndroid() { + var current = state() + #expect(current.inputPlaceholder == "댓글을 남겨보세요...") + current.editingCommentId = 3 + #expect(current.inputPlaceholder == "수정할 내용을 입력해주세요...") + } +} diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassBattleTranscriptLine.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassBattleTranscriptLine.swift index 28a35abc..a7a1ad34 100644 --- a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassBattleTranscriptLine.swift +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassBattleTranscriptLine.swift @@ -28,7 +28,10 @@ public struct ClassBattleTranscriptLine: Equatable, Identifiable, Sendable { } public extension ClassBattleTranscriptLine { - static func from(scenario: BattleScenario) -> [ClassBattleTranscriptLine] { + static func from( + scenario: BattleScenario, + selectedPaths: [Int: Int] = [:] + ) -> [ClassBattleTranscriptLine] { var transcript: [ClassBattleTranscriptLine] = [] var visibleNodeID = scenario.startNodeId var visitedNodeIDs = Set() @@ -81,7 +84,15 @@ public extension ClassBattleTranscriptLine { )) } } - guard let nextNodeID = scenario.nextNodeId(for: node) else { break } + let nextNodeID: Int? + if !node.interactiveOptions.isEmpty { + nextNodeID = node.interactiveOptions.first { + $0.nextNodeId == selectedPaths[node.nodeId] + }?.nextNodeId + } else { + nextNodeID = scenario.nextNodeId(for: node) + } + guard let nextNodeID else { break } visibleNodeID = nextNodeID } return transcript.sorted { diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveMenuKind.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveMenuKind.swift new file mode 100644 index 00000000..fb0c6928 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveMenuKind.swift @@ -0,0 +1,38 @@ +// +// ClassPerspectiveMenuKind.swift +// Class +// +// Created by Picke on 11/3/26. +// + +import ClassDomainInterface + +/// 클래스 의견 카드 더보기(•••) 메뉴 종류. Android `PerspectiveItemCard.perspectiveMenuItems`와 같다. +public enum ClassPerspectiveMenuKind: Equatable, Sendable { + case hidden + case report + case editOnly + case editAndDelete + + /// 목록 행: 서버가 남의 의견(false)이라고 알려 준 경우만 신고, 내 의견(true)은 상태 규칙, 모르면(nil) 메뉴 없음. + public init(listItem: ClassPerspectiveItem) { + switch listItem.isMyPerspective { + case .some(false): self = .report + case .some(true): self = .init(myStatus: listItem.status) + case .none: self = .hidden + } + } + + /// 내 의견: 검수 중은 메뉴 없음, 거절은 수정만, 그 외는 삭제·수정. + public init(myStatus status: String?) { + switch status?.uppercased() { + case "PENDING": self = .hidden + case "REJECTED": self = .editOnly + default: self = .editAndDelete + } + } + + public var hasMenu: Bool { + self != .hidden + } +} diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveRow.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveRow.swift index 29d9293d..1d6ece51 100644 --- a/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveRow.swift +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/Model/ClassPerspectiveRow.swift @@ -1,20 +1,36 @@ import ClassDomainInterface +import PickeCoreUtility public struct ClassPerspectiveRow: Equatable, Identifiable { public let id: Int public let perspective: ClassPerspectiveItem public let likeCountLabel: String public let commentCountLabel: String + public let menuKind: ClassPerspectiveMenuKind + + public var createdAtLabel: String { perspective.createdAt.relativeKoreanString } + + /// 공개되지 않은 내 의견의 상태 배지 문구(Android `comment_status_pending/rejected`). + public var statusBadgeTitle: String? { + if isPendingReview { return "검수중" } + if isRejected { return "거절됨" } + return nil + } + + public var isPendingReview: Bool { perspective.status?.uppercased() == "PENDING" } + public var isRejected: Bool { perspective.status?.uppercased() == "REJECTED" } public init( id: Int, perspective: ClassPerspectiveItem, likeCountLabel: String, - commentCountLabel: String + commentCountLabel: String, + menuKind: ClassPerspectiveMenuKind? = nil ) { self.id = id self.perspective = perspective self.likeCountLabel = likeCountLabel self.commentCountLabel = commentCountLabel + self.menuKind = menuKind ?? ClassPerspectiveMenuKind(listItem: perspective) } } diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift index 7d0ec44b..62f543fc 100644 --- a/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/Reducer/ClassBattleFlowFeature.swift @@ -7,6 +7,7 @@ import Foundation import HomeDomainInterface import PickeCoreUtility import PerspectiveDomainInterface +import PickeSharedUI @Reducer public struct ClassBattleFlowFeature { @@ -43,6 +44,7 @@ public struct ClassBattleFlowFeature { public var hasMorePerspectives = false public var isLoadingPerspectives = false public var refreshAfterPerspectiveDetail = false + public var refreshAfterOwnPerspectiveDetail = false public var perspectiveErrorMessage: String? public var errorMessage: String? public var loadRequestID: UUID? @@ -52,6 +54,15 @@ public struct ClassBattleFlowFeature { public var voteStatsRequestID: UUID? public var perspectiveLikeRequestID: UUID? public var myPerspectiveRequestID: UUID? + /// 내 의견 API의 첫 항목. 공개되지 않았으면 목록 위 상태 카드로 보여 준다. + public var myPerspective: ClassPerspectiveItem? + public var menuTargetPerspectiveID: Int? + public var editingPerspectiveID: Int? + public var deleteTargetPerspectiveID: Int? + public var reportTargetPerspectiveID: Int? + public var mutationRequestID: UUID? + public var toastMessage: String? + @Presents public var customAlert: CustomAlertState? public var isAudioReady = false public var isPlaying = false public var hasListened = false @@ -59,6 +70,16 @@ public struct ClassBattleFlowFeature { public var audioDuration: TimeInterval = 0 public var playbackRate: Float = 1 public var transcript: [ClassBattleTranscriptLine] = [] + public var scenario: BattleScenario? + public var currentNodeID: Int? + public var pendingCheckpointID: Int? + public var selectedMidOptionID: Int? + public var selectedPaths: [Int: Int] = [:] + public var loadedAudioURL: URL? + public var midVoteRequestID: UUID? + public var pendingSeekTime: TimeInterval? + public var needsMidVoteRecovery = false + public var midVoteDisabledLocally = false public var selectedPerspectiveOptionID: Int? public var perspectiveSort: String = "popular" @@ -69,14 +90,23 @@ public struct ClassBattleFlowFeature { public var canParticipate: Bool { session?.canParticipate == true } public var canSubmit: Bool { + // 의견 수정은 이미 게시한 의견이 대상이라 게시 조건과 별도로 판단하고, 마감 여부는 서버에 맡긴다. + if editingPerspectiveID != nil { + return mutationRequestID == nil && submitRequestID == nil + && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } guard viewState == .loaded, canParticipate, submitRequestID == nil else { return false } guard let step else { return false } switch step { - case .none, .postVote: return selectedOptionID != nil + case .none: return selectedOptionID != nil + case .postVote: + return selectedOptionID != nil + && (!needsMidVoteRecovery || (hasListened && pendingCheckpointID == nil)) case .preVote: guard selectedOptionID != nil else { return false } - return selectedOptionID != voteHistory?.preVote.optionID || hasListened - case .ttsListening: return hasListened + return (!isPreVoteLocked && selectedOptionID != voteHistory?.preVote.optionID) || hasListened + case .ttsListening: + return hasListened && pendingCheckpointID == nil && hasAnsweredCurrentPath case .completed: return !hasSubmittedInCurrentSession && hasPublishedPerspective == false @@ -84,12 +114,18 @@ public struct ClassBattleFlowFeature { } } + public var isPreVoteLocked: Bool { + session?.midVoteEnabled == true + && (!(voteHistory?.midVotes.isEmpty ?? true) || !selectedPaths.isEmpty) + } + public var buttonTitle: String { guard let step else { return "불러오는 중" } switch step { case .none: return "사전 투표하기" case .preVote: - return selectedOptionID != voteHistory?.preVote.optionID ? "사전투표 변경하기" : "청취 완료" + return !isPreVoteLocked && selectedOptionID != voteHistory?.preVote.optionID + ? "사전투표 변경하기" : "청취 완료" case .ttsListening: return "청취 완료" case .postVote: return "최종 투표하기" case .completed: @@ -144,12 +180,42 @@ public struct ClassBattleFlowFeature { } public var shouldShowComposer: Bool { - step == .some(.completed) && canParticipate + if editingPerspectiveID != nil { return true } + return step == .some(.completed) && canParticipate && hasPublishedPerspective == false && !hasSubmittedInCurrentSession } + /// 공개되지 않은 내 의견(검수중·거절됨)이 목록에 없을 때 목록 맨 위에 보여 줄 행. + public var hiddenMyPerspectiveRow: ClassPerspectiveRow? { + guard let myPerspective, + ["PENDING", "REJECTED"].contains(myPerspective.status?.uppercased() ?? ""), + !perspectives.contains(where: { $0.id == myPerspective.id }) else { return nil } + return ClassPerspectiveRow( + id: -1, + perspective: myPerspective, + likeCountLabel: myPerspective.likeCount.decimalFormatted, + commentCountLabel: myPerspective.commentCount.decimalFormatted, + menuKind: ClassPerspectiveMenuKind(myStatus: myPerspective.status) + ) + } + + func perspectiveItem(id: Int) -> ClassPerspectiveItem? { + perspectives.first { $0.id == id } ?? (myPerspective?.id == id ? myPerspective : nil) + } + + func perspectiveMenuKind(id: Int) -> ClassPerspectiveMenuKind { + if let item = perspectives.first(where: { $0.id == id }) { + return ClassPerspectiveMenuKind(listItem: item) + } + if let myPerspective, myPerspective.id == id { + return ClassPerspectiveMenuKind(myStatus: myPerspective.status) + } + return .hidden + } + public var isAudioStep: Bool { step == .some(.preVote) || step == .some(.ttsListening) + || (step == .some(.postVote) && needsMidVoteRecovery) } public var audioProgress: Double { @@ -169,6 +235,38 @@ public struct ClassBattleFlowFeature { transcript.filter { $0.startTime <= currentTime } } + public var midVoteOptions: [ScenarioInteractiveOption] { + guard let pendingCheckpointID else { return [] } + return scenario?.nodes.first { $0.nodeId == pendingCheckpointID }?.interactiveOptions ?? [] + } + + public var currentNode: ScenarioNode? { + guard let scenario else { return nil } + return scenario.nodes.first { $0.nodeId == (currentNodeID ?? scenario.startNodeId) } + } + + public var canConfirmMidVote: Bool { + pendingCheckpointID != nil && selectedMidOptionID != nil && midVoteRequestID == nil + } + + public var hasAnsweredCurrentPath: Bool { + guard session?.midVoteEnabled == true, let scenario else { return true } + var nodeID: Int? = scenario.startNodeId + var visited = Set() + while let id = nodeID, visited.insert(id).inserted, + let node = scenario.nodes.first(where: { $0.nodeId == id }) { + if !node.interactiveOptions.isEmpty { + guard let chosenNextID = selectedPaths[id], + node.interactiveOptions.contains(where: { $0.nextNodeId == chosenNextID }) + else { return false } + nodeID = chosenNextID + } else { + nodeID = scenario.nextNodeId(for: node) + } + } + return true + } + public var visibleTranscriptRows: [ClassBattleTranscriptRow] { let lines = visibleTranscript return lines.enumerated().map { index, line in @@ -202,7 +300,7 @@ public struct ClassBattleFlowFeature { ?? voteHistory.postVote.map({ $0.optionID != voteHistory.preVote.optionID }) else { return nil } - return changed ? "생각이 바뀌었어요" : "처음 생각을 유지했어요" + return changed ? "생각이 바뀌었어요" : "생각이 동일해요" } public var displayArtworkURL: URL? { @@ -226,9 +324,15 @@ public struct ClassBattleFlowFeature { public enum Action: ViewAction { case view(View) case inner(InnerAction) + case scope(ScopeAction) case delegate(DelegateAction) } + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + @CasePathable public enum View: Equatable { case onAppear @@ -248,23 +352,35 @@ public struct ClassBattleFlowFeature { case scrub(TimeInterval) case replayTapped case playbackRateSelected(Float) + case midVoteOptionTapped(Int) + case confirmMidVoteTapped case perspectiveOptionTapped(Int?) case perspectiveSortTapped(String) case perspectiveTapped(Int) case perspectiveLikeTapped(Int) + case perspectiveMoreTapped(Int) + case perspectiveEditTapped(Int) + case perspectiveDeleteTapped(Int) + case perspectiveReportTapped(Int) + case cancelEditTapped + case toastShown } public enum InnerAction: Equatable { case progressResponse(UUID, Result) case submissionResponse(UUID, ClassBattleStep, Result) case audioURLResponse(UUID, Result) - case transcriptLoaded(UUID, [ClassBattleTranscriptLine]) + case scenarioLoaded(UUID, BattleScenario) + case midVoteResponse(UUID, Int, Int, Result) case audioLoaded(UUID, Bool, TimeInterval) case playerTimeUpdated(UUID, TimeInterval) case perspectivePageResponse(UUID, Result) case voteStatsResponse(UUID, Result) case perspectiveLikeResponse(UUID, Int, Result) case myPerspectiveResponse(UUID, Result<[ClassPerspectiveItem], ClassBattleError>) + case updatePerspectiveResponse(UUID, Result) + case deletePerspectiveResponse(UUID, Int, Result) + case reportPerspectiveResponse(UUID, Result) } @CasePathable @@ -284,6 +400,9 @@ public struct ClassBattleFlowFeature { case voteStats(UUID) case perspectiveLike(UUID) case myPerspective(UUID) + case midVote(UUID) + /// 의견 수정·삭제·신고. 상세 화면 이동(onDisappear)에서는 취소하지 않고 라우트 제거 시에만 취소한다. + case perspectiveMutation(UUID) } @Dependency(\.uuid) private var uuid @@ -297,9 +416,13 @@ public struct ClassBattleFlowFeature { switch action { case let .view(viewAction): return handleView(state: &state, action: viewAction) case let .inner(innerAction): return handleInner(state: &state, action: innerAction) + case let .scope(scopeAction): return handleScope(state: &state, action: scopeAction) case .delegate: return .none } } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } } } @@ -324,8 +447,12 @@ extension ClassBattleFlowFeature { if state.step == .some(.completed), state.perspectiveRequestID == nil { let shouldRefresh = state.refreshAfterPerspectiveDetail + let returnedFromOwnPerspective = shouldRefresh && state.refreshAfterOwnPerspectiveDetail state.refreshAfterPerspectiveDetail = false + state.refreshAfterOwnPerspectiveDetail = false if shouldRefresh { state.hasSubmittedInCurrentSession = false } + // 내 의견 상세에서 삭제 후 돌아왔을 수 있으므로 /me 결과로 다시 판정한다. + if returnedFromOwnPerspective { state.hasPublishedPerspective = nil } return .merge( state.perspectives.isEmpty || shouldRefresh ? loadPerspectives(state: &state, cursor: nil, preserveExisting: shouldRefresh) : .none, @@ -354,6 +481,8 @@ extension ClassBattleFlowFeature { state.pendingPerspectiveLikeResults = [:] state.voteStatsRequestID = nil state.myPerspectiveRequestID = nil + state.midVoteRequestID = nil + state.menuTargetPerspectiveID = nil state.isLoadingPerspectives = false state.isPlaying = false return stopAudio(instanceID: state.instanceID) @@ -373,7 +502,12 @@ extension ClassBattleFlowFeature { guard state.step == .some(.completed), state.hasPublishedPerspective == nil, state.myPerspectiveRequestID == nil else { return .none } state.perspectiveErrorMessage = nil - return loadMyPerspectives(state: &state) + // 목록도 실패해 비어 있으면 함께 다시 불러온다. 오류 문구만 지우면 빈 목록처럼 보인다. + let reloadsPerspectives = state.perspectives.isEmpty && state.perspectiveRequestID == nil + return .merge( + reloadsPerspectives ? loadPerspectives(state: &state, cursor: nil) : .none, + loadMyPerspectives(state: &state) + ) case .backTapped: let instanceID = state.instanceID return .concatenate( @@ -384,6 +518,7 @@ extension ClassBattleFlowFeature { guard state.canParticipate, let step = state.step, [.none, .preVote, .postVote].contains(step), + !(step == .preVote && state.isPreVoteLocked), state.options.contains(where: { $0.id == optionID }), state.submitRequestID == nil else { return .none } @@ -404,6 +539,9 @@ extension ClassBattleFlowFeature { return .none case .submitTapped: guard state.canSubmit else { return .none } + if let editingID = state.editingPerspectiveID { + return updatePerspective(state: &state, id: editingID) + } guard let step = state.step else { return .none } let requestID = uuid() state.submitRequestID = requestID @@ -411,6 +549,7 @@ extension ClassBattleFlowFeature { guard let classID = state.room?.id else { return .none } let optionID = state.selectedOptionID let preVoteOptionID = state.voteHistory?.preVote.optionID + let isPreVoteLocked = state.isPreVoteLocked let draft = state.draft.trimmingCharacters(in: .whitespacesAndNewlines) return .run { [useCase = classUseCase] send in let result = await Result { @@ -419,7 +558,7 @@ extension ClassBattleFlowFeature { guard let optionID else { throw ClassBattleError.invalidOption } _ = try await useCase.submitPreVote(classID: classID, optionID: optionID) case .preVote: - if optionID != preVoteOptionID { + if !isPreVoteLocked && optionID != preVoteOptionID { guard let optionID else { throw ClassBattleError.invalidOption } _ = try await useCase.submitPreVote(classID: classID, optionID: optionID) } else { @@ -439,26 +578,78 @@ extension ClassBattleFlowFeature { await send(.inner(.submissionResponse(requestID, step, result))) } .cancellable(id: CancelID.submit(state.instanceID), cancelInFlight: true) + case let .midVoteOptionTapped(nextNodeID): + guard state.pendingCheckpointID != nil, + state.midVoteOptions.contains(where: { $0.nextNodeId == nextNodeID }), + state.midVoteRequestID == nil else { return .none } + state.selectedMidOptionID = nextNodeID + state.errorMessage = nil + return .none + case .confirmMidVoteTapped: + guard state.canConfirmMidVote, + let checkpointID = state.pendingCheckpointID, + let nextNodeID = state.selectedMidOptionID, + let option = state.midVoteOptions.first(where: { $0.nextNodeId == nextNodeID }), + let classID = state.room?.id else { return .none } + guard let session = state.session else { return .none } + if !session.midVoteEnabled || state.midVoteDisabledLocally { + return advanceAfterChoice(state: &state, checkpointID: checkpointID, option: option) + } + guard let optionID = option.optionID else { + state.errorMessage = ClassBattleError.invalidResponse.message + return .none + } + let requestID = uuid() + state.midVoteRequestID = requestID + state.errorMessage = nil + return .run { [useCase = classUseCase] send in + let result = await Result { + _ = try await useCase.submitMidVote( + classID: classID, + checkpointID: checkpointID, + optionID: optionID + ) + return true + }.mapError(Self.mapError) + guard !Task.isCancelled else { return } + await send(.inner(.midVoteResponse(requestID, checkpointID, optionID, result))) + } + .cancellable(id: CancelID.midVote(state.instanceID), cancelInFlight: true) case .playbackTapped: - guard state.isAudioStep, state.isAudioReady else { return .none } + guard state.isAudioStep, state.isAudioReady, + state.pendingCheckpointID == nil else { return .none } state.isPlaying.toggle() return .run { [player = audioPlayer, shouldPlay = state.isPlaying] _ in if shouldPlay { await player.play() } else { await player.pause() } } .cancellable(id: CancelID.playback(state.instanceID), cancelInFlight: true) case let .seekBy(delta): - guard state.isAudioStep, state.isAudioReady else { return .none } - let target = min(state.audioDuration, max(0, state.currentTime + delta)) + guard state.isAudioStep, state.isAudioReady, + state.pendingCheckpointID == nil else { return .none } + let checkpointBoundary = state.currentNode.flatMap { node in + node.interactiveOptions.isEmpty ? nil : state.scenario?.nodeEndTime(for: node) + } + let target = min( + state.audioDuration, + min(checkpointBoundary ?? .infinity, max(0, state.currentTime + delta)) + ) state.currentTime = target + state.pendingSeekTime = target return .run { [player = audioPlayer] _ in await player.seek(to: target) } case let .scrub(time): - guard state.isAudioStep, state.isAudioReady, state.hasListened else { return .none } + guard state.isAudioStep, state.isAudioReady, state.hasListened, + state.pendingCheckpointID == nil else { return .none } let target = min(state.audioDuration, max(0, time)) state.currentTime = target + state.pendingSeekTime = target return .run { [player = audioPlayer] _ in await player.seek(to: target) } case .replayTapped: guard state.isAudioStep, state.isAudioReady, state.hasListened else { return .none } state.currentTime = 0 + state.currentNodeID = state.scenario?.startNodeId + state.pendingCheckpointID = nil + state.selectedMidOptionID = nil + state.pendingSeekTime = 0 state.isPlaying = true return .run { [player = audioPlayer] _ in await player.seek(to: 0) @@ -482,6 +673,8 @@ extension ClassBattleFlowFeature { guard state.step == .some(.completed), state.submitRequestID == nil, state.perspectives.contains(where: { $0.id == id }) else { return .none } state.refreshAfterPerspectiveDetail = true + state.refreshAfterOwnPerspectiveDetail = + state.perspectives.first(where: { $0.id == id })?.isMyPerspective == true return .send(.delegate(.openPerspective(id))) case let .perspectiveLikeTapped(id): guard state.step == .some(.completed), state.canParticipate, @@ -500,7 +693,122 @@ extension ClassBattleFlowFeature { await send(.inner(.perspectiveLikeResponse(requestID, id, result))) } .cancellable(id: CancelID.perspectiveLike(state.instanceID), cancelInFlight: true) + case let .perspectiveMoreTapped(id): + // 진행 중인 수정·삭제·신고가 같은 CancelID로 취소되지 않게 한 번에 하나만 허용한다. + guard state.mutationRequestID == nil, state.perspectiveMenuKind(id: id).hasMenu else { return .none } + state.menuTargetPerspectiveID = state.menuTargetPerspectiveID == id ? nil : id + return .none + case let .perspectiveEditTapped(id): + state.menuTargetPerspectiveID = nil + guard state.mutationRequestID == nil, + [.editOnly, .editAndDelete].contains(state.perspectiveMenuKind(id: id)), + let item = state.perspectiveItem(id: id) else { return .none } + if state.editingPerspectiveID != id { + state.draft = item.content + } + state.editingPerspectiveID = id + state.errorMessage = nil + return .none + case let .perspectiveDeleteTapped(id): + state.menuTargetPerspectiveID = nil + guard state.mutationRequestID == nil, state.perspectiveMenuKind(id: id) == .editAndDelete else { return .none } + state.deleteTargetPerspectiveID = id + state.reportTargetPerspectiveID = nil + state.customAlert = .deletePerspective() + return .none + case let .perspectiveReportTapped(id): + state.menuTargetPerspectiveID = nil + guard state.mutationRequestID == nil, state.perspectiveMenuKind(id: id) == .report else { return .none } + state.reportTargetPerspectiveID = id + state.deleteTargetPerspectiveID = nil + state.customAlert = .report() + return .none + case .cancelEditTapped: + state.editingPerspectiveID = nil + state.draft = "" + return .none + case .toastShown: + state.toastMessage = nil + return .none + } + } + + private func handleScope( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case .customAlert(.presented(.confirmTapped)): + state.customAlert = nil + guard state.mutationRequestID == nil else { + state.deleteTargetPerspectiveID = nil + state.reportTargetPerspectiveID = nil + return .none + } + if let id = state.deleteTargetPerspectiveID { + state.deleteTargetPerspectiveID = nil + return deletePerspective(state: &state, id: id) + } + if let id = state.reportTargetPerspectiveID { + state.reportTargetPerspectiveID = nil + return reportPerspective(state: &state, id: id) + } + return .none + case .customAlert(.presented(.cancelTapped)), .customAlert(.dismiss): + state.customAlert = nil + state.deleteTargetPerspectiveID = nil + state.reportTargetPerspectiveID = nil + return .none + } + } + + private func updatePerspective( + state: inout State, + id: Int + ) -> Effect { + let requestID = uuid() + state.mutationRequestID = requestID + let content = state.draft.trimmingCharacters(in: .whitespacesAndNewlines) + return .run { [useCase = perspectiveUseCase] send in + let result = await Result { + try await useCase.updatePerspective(perspectiveId: id, content: content) + return true + }.mapError(PerspectiveError.from) + await send(.inner(.updatePerspectiveResponse(requestID, result))) + } + .cancellable(id: CancelID.perspectiveMutation(state.instanceID), cancelInFlight: true) + } + + private func deletePerspective( + state: inout State, + id: Int + ) -> Effect { + let requestID = uuid() + state.mutationRequestID = requestID + return .run { [useCase = perspectiveUseCase] send in + let result = await Result { + try await useCase.deletePerspective(perspectiveId: id) + return true + }.mapError(PerspectiveError.from) + await send(.inner(.deletePerspectiveResponse(requestID, id, result))) + } + .cancellable(id: CancelID.perspectiveMutation(state.instanceID), cancelInFlight: true) + } + + private func reportPerspective( + state: inout State, + id: Int + ) -> Effect { + let requestID = uuid() + state.mutationRequestID = requestID + return .run { [useCase = perspectiveUseCase] send in + let result = await Result { + try await useCase.reportPerspective(perspectiveId: id) + return true + }.mapError(PerspectiveError.from) + await send(.inner(.reportPerspectiveResponse(requestID, result))) } + .cancellable(id: CancelID.perspectiveMutation(state.instanceID), cancelInFlight: true) } private func loadProgress(state: inout State) -> Effect { @@ -536,6 +844,17 @@ extension ClassBattleFlowFeature { state.viewState = .loaded state.session = progress.session state.voteHistory = progress.voteHistory + if progress.session.midVoteEnabled { + state.selectedPaths = state.scenario.map { + Self.selectedPaths(from: progress.voteHistory, scenario: $0) + } ?? [:] + if let scenario = state.scenario { + state.transcript = ClassBattleTranscriptLine.from( + scenario: scenario, + selectedPaths: state.selectedPaths + ) + } + } if progress.session.battle.currentStep == .completed { if state.hasSubmittedInCurrentSession || (progress.session.commentRequired && progress.session.participationCompleted) { @@ -561,7 +880,8 @@ extension ClassBattleFlowFeature { stopPlayback(instanceID: state.instanceID), loadPerspectives(state: &state, cursor: nil), loadVoteStats(state: &state), - state.hasPublishedPerspective == nil ? loadMyPerspectives(state: &state) : .none + // 공개되지 않은 내 의견 카드·거절 의견 수정을 위해 완료 단계마다 내 의견을 다시 받는다(Android 제출 후 fetchMyPerspective). + loadMyPerspectives(state: &state) ) } return stopPlayback(instanceID: state.instanceID) @@ -577,6 +897,7 @@ extension ClassBattleFlowFeature { case .success: state.draft = "" state.hasAdvancedInCurrentSession = true + if submittedStep == .postVote { state.needsMidVoteRecovery = false } if submittedStep == .completed { state.hasSubmittedInCurrentSession = true state.hasPublishedPerspective = true @@ -584,12 +905,35 @@ extension ClassBattleFlowFeature { return loadProgress(state: &state) case let .failure(error): state.errorMessage = error.message + if submittedStep == .postVote, error == .midVoteRequired { + state.needsMidVoteRecovery = true + state.hasListened = false + state.currentNodeID = state.scenario?.startNodeId + state.pendingCheckpointID = nil + state.selectedMidOptionID = nil + state.currentTime = 0 + state.pendingSeekTime = 0 + if state.isAudioReady { + state.isPlaying = true + return .merge( + .run { [player = audioPlayer] _ in + await player.seek(to: 0) + await player.play() + }, + observePlayerTime(instanceID: state.instanceID) + ) + } + if let battleID = state.session?.battle.id { + return loadAudio(state: &state, battleID: battleID) + } + } return .none } case let .audioURLResponse(requestID, result): guard state.audioRequestID == requestID, state.isAudioStep else { return .none } switch result { case let .success(url): + state.loadedAudioURL = url return .run { [player = audioPlayer] send in let loaded = await player.load(url: url) let duration = loaded ? await player.duration() : 0 @@ -602,6 +946,47 @@ extension ClassBattleFlowFeature { state.audioRequestID = nil return .none } + case let .scenarioLoaded(requestID, scenario): + guard state.audioRequestID == requestID, state.isAudioStep else { return .none } + state.scenario = scenario + state.currentNodeID = scenario.startNodeId + state.pendingCheckpointID = nil + state.selectedMidOptionID = nil + if state.session?.midVoteEnabled == true { + state.selectedPaths = Self.selectedPaths(from: state.voteHistory, scenario: scenario) + } + state.transcript = ClassBattleTranscriptLine.from( + scenario: scenario, + selectedPaths: state.selectedPaths + ) + return .none + case let .midVoteResponse(requestID, checkpointID, optionID, result): + guard state.midVoteRequestID == requestID, + state.pendingCheckpointID == checkpointID, + let option = state.midVoteOptions.first(where: { $0.optionID == optionID }) + else { return .none } + state.midVoteRequestID = nil + switch result { + case .success: + if state.step == .some(.preVote) { + state.selectedOptionID = state.voteHistory?.preVote.optionID + } + return advanceAfterChoice(state: &state, checkpointID: checkpointID, option: option) + case let .failure(error): + state.errorMessage = error.message + if error == .midVoteUnavailable { + let effect = advanceAfterChoice(state: &state, checkpointID: checkpointID, option: option) + state.midVoteDisabledLocally = true + state.errorMessage = error.message + return effect + } + if error == .midVoteLocked || error == .invalidMidVoteOption || error == .voteNotFound { + state.pendingCheckpointID = nil + state.isAudioReady = false + return .merge(stopPlayback(instanceID: state.instanceID), loadProgress(state: &state)) + } + return .none + } case let .audioLoaded(requestID, loaded, duration): guard state.audioRequestID == requestID, state.isAudioStep else { return .none } state.audioRequestID = nil @@ -612,20 +997,28 @@ extension ClassBattleFlowFeature { state.isAudioReady = true state.audioDuration = duration state.isPlaying = true + let seekTime = state.pendingSeekTime return .merge( - .run { [player = audioPlayer] _ in await player.play() } + .run { [player = audioPlayer] _ in + if let seekTime { await player.seek(to: seekTime) } + await player.play() + } .cancellable(id: CancelID.playback(state.instanceID), cancelInFlight: true), observePlayerTime(instanceID: state.instanceID) ) - case let .transcriptLoaded(requestID, transcript): - guard state.audioRequestID == requestID, state.isAudioStep else { return .none } - state.transcript = transcript - return .none case let .playerTimeUpdated(instanceID, time): guard state.instanceID == instanceID, state.isAudioStep, - state.isAudioReady else { return .none } + state.isAudioReady, state.pendingCheckpointID == nil else { return .none } + if let pendingSeekTime = state.pendingSeekTime { + guard abs(time - pendingSeekTime) < 1 else { return .none } + state.pendingSeekTime = nil + } state.currentTime = min(state.audioDuration, max(0, time)) - guard time >= max(0, state.audioDuration - 0.3) else { return .none } + if let effect = advanceNodeIfNeeded(state: &state, time: time) { + return effect + } + guard state.pendingCheckpointID == nil, + time >= max(0, state.audioDuration - 0.3) else { return .none } state.hasListened = true guard state.isPlaying else { return .none } state.isPlaying = false @@ -687,9 +1080,64 @@ extension ClassBattleFlowFeature { state.myPerspectiveRequestID = nil switch result { case let .success(items): - state.hasPublishedPerspective = !items.isEmpty || state.hasSubmittedInCurrentSession + state.myPerspective = items.first + // 서버가 참여 완료로 판정한 뒤에는 /me가 비어도 입력창을 다시 열지 않는다(중복 의견 방지). + state.hasPublishedPerspective = state.hasPublishedPerspective == true + || !items.isEmpty + || state.hasSubmittedInCurrentSession case let .failure(error): - state.perspectiveErrorMessage = error.message + // 판정 전 실패만 재시도 문구를 띄운다. 이미 판정됐으면 목록 오류 문구를 덮지 않는다. + if state.hasPublishedPerspective == nil { + state.perspectiveErrorMessage = error.message + } + } + return .none + case let .updatePerspectiveResponse(requestID, result): + guard state.mutationRequestID == requestID else { return .none } + state.mutationRequestID = nil + switch result { + case .success: + state.editingPerspectiveID = nil + state.draft = "" + return .merge( + loadPerspectives(state: &state, cursor: nil, preserveExisting: true), + loadMyPerspectives(state: &state) + ) + case .failure: + state.toastMessage = "의견을 수정하지 못했어요" + return .none + } + case let .deletePerspectiveResponse(requestID, id, result): + guard state.mutationRequestID == requestID else { return .none } + state.mutationRequestID = nil + switch result { + case .success: + // Android와 같이 삭제한 내 의견을 바로 빼고 새 의견을 다시 쓸 수 있게 한다. + state.perspectives.removeAll { $0.id == id } + if state.myPerspective?.id == id { state.myPerspective = nil } + if state.editingPerspectiveID == id { + state.editingPerspectiveID = nil + state.draft = "" + } + state.hasPublishedPerspective = false + state.hasSubmittedInCurrentSession = false + case .failure: + state.toastMessage = "의견을 삭제하지 못했어요" + } + return .merge( + loadPerspectives(state: &state, cursor: nil, preserveExisting: true), + loadMyPerspectives(state: &state) + ) + case let .reportPerspectiveResponse(requestID, result): + guard state.mutationRequestID == requestID else { return .none } + state.mutationRequestID = nil + switch result { + case .success: + state.toastMessage = "신고가 정상 접수되었습니다." + case .failure(.alreadyReported): + state.toastMessage = "이미 신고한 사용자입니다." + case .failure: + state.toastMessage = "신고하지 못했어요" } return .none } @@ -764,22 +1212,28 @@ extension ClassBattleFlowFeature { let requestID = uuid() state.audioRequestID = requestID state.errorMessage = nil - return .run { [useCase = battleUseCase] send in + return .run { [ + useCase = battleUseCase, + history = state.voteHistory, + priorPaths = state.selectedPaths + ] send in let result = await Result { let scenario = try await useCase.fetchScenario(battleId: battleID) - guard let rawURL = scenario.audios[scenario.recommendedPathKey.rawValue] + let paths = Self.selectedPaths(from: history, scenario: scenario) + let chosenNodeID = (paths.isEmpty ? priorPaths : paths).values.first + let pathKey = chosenNodeID.flatMap { Self.branchPathKey(from: $0, scenario: scenario) } + guard let rawURL = pathKey.flatMap({ scenario.audios[$0] }) + ?? scenario.audios[scenario.recommendedPathKey.rawValue] ?? scenario.audios.values.first, let url = URL(string: rawURL), ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { throw ClassBattleError.invalidResponse } - return (url, ClassBattleTranscriptLine.from(scenario: scenario)) + return (url, scenario) }.mapError(Self.mapError) guard !Task.isCancelled else { return } switch result { - case let .success((url, transcript)): - if !transcript.isEmpty { - await send(.inner(.transcriptLoaded(requestID, transcript))) - } + case let .success((url, scenario)): + await send(.inner(.scenarioLoaded(requestID, scenario))) await send(.inner(.audioURLResponse(requestID, .success(url)))) case let .failure(error): await send(.inner(.audioURLResponse(requestID, .failure(error)))) @@ -788,6 +1242,113 @@ extension ClassBattleFlowFeature { .cancellable(id: CancelID.audio(state.instanceID), cancelInFlight: true) } + private func advanceNodeIfNeeded( + state: inout State, + time: TimeInterval + ) -> Effect? { + guard let scenario = state.scenario, state.pendingCheckpointID == nil else { return nil } + var visited = Set() + while let node = state.currentNode, visited.insert(node.nodeId).inserted, + time >= scenario.nodeEndTime(for: node) { + if !node.interactiveOptions.isEmpty { + if let selectedNextID = state.selectedPaths[node.nodeId], + let selectedOption = node.interactiveOptions.first(where: { $0.nextNodeId == selectedNextID }) { + return advanceAfterChoice(state: &state, checkpointID: node.nodeId, option: selectedOption) + } + state.pendingCheckpointID = node.nodeId + state.selectedMidOptionID = state.selectedPaths[node.nodeId] + state.currentTime = scenario.nodeEndTime(for: node) + state.isPlaying = false + return .run { [player = audioPlayer] _ in await player.pause() } + } + guard let nextNodeID = scenario.nextNodeId(for: node), + scenario.nodes.contains(where: { $0.nodeId == nextNodeID }) + else { return nil } + state.currentNodeID = nextNodeID + } + return nil + } + + private func advanceAfterChoice( + state: inout State, + checkpointID: Int, + option: ScenarioInteractiveOption + ) -> Effect { + guard let scenario = state.scenario else { return .none } + let pathKey = Self.branchPathKey(from: option.nextNodeId, scenario: scenario) + let branchURL: URL? + if let pathKey, let rawURL = scenario.audios[pathKey] { + branchURL = URL(string: rawURL) + } else if pathKey != nil, + scenario.audios["PATH_A"] != nil || scenario.audios["PATH_B"] != nil { + state.errorMessage = ClassBattleError.invalidResponse.message + return .none + } else { + branchURL = state.loadedAudioURL + } + state.selectedPaths[checkpointID] = option.nextNodeId + state.transcript = ClassBattleTranscriptLine.from( + scenario: scenario, + selectedPaths: state.selectedPaths + ) + state.currentNodeID = option.nextNodeId + state.pendingCheckpointID = nil + state.selectedMidOptionID = nil + state.errorMessage = nil + let targetTime = scenario.nodeStartTime(for: option.nextNodeId) + state.currentTime = targetTime + state.pendingSeekTime = targetTime + state.isPlaying = true + if let branchURL, branchURL != state.loadedAudioURL { + let requestID = uuid() + state.audioRequestID = requestID + state.loadedAudioURL = branchURL + state.isAudioReady = false + state.isPlaying = false + return .run { [player = audioPlayer] send in + let loaded = await player.load(url: branchURL) + let duration = loaded ? await player.duration() : 0 + guard !Task.isCancelled else { return } + await send(.inner(.audioLoaded(requestID, loaded, duration))) + } + .cancellable(id: CancelID.audio(state.instanceID), cancelInFlight: true) + } + return .run { [player = audioPlayer] _ in + await player.seek(to: targetTime) + await player.play() + } + } + + private static func selectedPaths( + from history: ClassVoteHistory?, + scenario: BattleScenario + ) -> [Int: Int] { + Dictionary( + history?.midVotes.compactMap { choice in + scenario.nodes.first(where: { $0.nodeId == choice.checkpointID })? + .interactiveOptions.first(where: { $0.optionID == choice.optionID }) + .map { (choice.checkpointID, $0.nextNodeId) } + } ?? [], + uniquingKeysWith: { _, newest in newest } + ) + } + + private static func branchPathKey( + from firstNodeID: Int, + scenario: BattleScenario + ) -> String? { + var nodeID: Int? = firstNodeID + var visited = Set() + while let id = nodeID, visited.insert(id).inserted, + let node = scenario.nodes.first(where: { $0.nodeId == id }) { + let name = node.nodeName.uppercased() + if name.contains("_A") { return "PATH_A" } + if name.contains("_B") { return "PATH_B" } + nodeID = node.autoNextNodeId + } + return nil + } + private func observePlayerTime(instanceID: UUID) -> Effect { .run { [player = audioPlayer] send in for await time in player.currentTimes() { @@ -802,6 +1363,7 @@ extension ClassBattleFlowFeature { .merge( .cancel(id: CancelID.load(instanceID)), .cancel(id: CancelID.submit(instanceID)), + .cancel(id: CancelID.midVote(instanceID)), .cancel(id: CancelID.perspectives(instanceID)), .cancel(id: CancelID.perspectiveLike(instanceID)), .cancel(id: CancelID.myPerspective(instanceID)), diff --git a/Projects/Feature/Class/Sources/ClassBattleFlow/View/ClassBattleFlowView.swift b/Projects/Feature/Class/Sources/ClassBattleFlow/View/ClassBattleFlowView.swift index 299b339d..90123f09 100644 --- a/Projects/Feature/Class/Sources/ClassBattleFlow/View/ClassBattleFlowView.swift +++ b/Projects/Feature/Class/Sources/ClassBattleFlow/View/ClassBattleFlowView.swift @@ -22,7 +22,10 @@ public struct ClassBattleFlowView: View { case .error: errorScreen() case .loaded: switch store.step { - case .some(.none), .some(.postVote): voteScreen() + case .some(.none): voteScreen() + case .some(.postVote): + if store.needsMidVoteRecovery { listeningScreen() } + else { voteScreen() } case .some(.preVote): listeningScreen() case .some(.ttsListening): listeningScreen() case .some(.completed): @@ -35,13 +38,18 @@ public struct ClassBattleFlowView: View { .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } .onDisappear { send(.onDisappear) } + .onChange(of: store.toastMessage) { _, message in + guard let message else { return } + ToastManager.shared.showInfo(message) + send(.toastShown) + } } } private extension ClassBattleFlowView { var isFinalVote: Bool { store.step == .some(.postVote) } var isPreVoteLanding: Bool { store.step == .some(.completed) && store.showsPreVoteLanding } - var voteBackground: Color { isFinalVote ? .black : .white } + var voteBackground: Color { isFinalVote ? .gray900 : .white } @ViewBuilder func loadingScreen() -> some View { @@ -90,20 +98,20 @@ private extension ClassBattleFlowView { height: isPreVoteLanding ? max(420, min(510, proxy.size.height * 0.63)) : isFinalVote - ? max(370, min(446, proxy.size.height * 0.56)) - : max(315, min(390, proxy.size.height * 0.48)) + ? max(330, min(405, proxy.size.height * 0.5 + 15)) + : max(315, min(390, proxy.size.height * 0.5)) ) voteIntroduction() if !isPreVoteLanding { optionSection() - .padding(.top, isFinalVote ? 36 : 40) + .padding(.top, 40) } } .padding(.horizontal, 16) .frame(minHeight: max(0, proxy.size.height - 110), alignment: .top) } .safeAreaInset(edge: .bottom, spacing: 0) { - voteFooter() + voteFooter(bottomPadding: voteFooterBottomPadding(hasBottomSafeArea: proxy.safeAreaInsets.bottom > 0)) .padding(.horizontal, 16) .padding(.top, 12) .background(voteBackground) @@ -179,39 +187,63 @@ private extension ClassBattleFlowView { @ViewBuilder func voteIntroduction() -> some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { if let category = store.voteCategoryLabel { - Text(category) - .pretendardFont(.medium11) - .foregroundStyle(isFinalVote ? .beige200 : .primary500) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(isFinalVote ? Color.clear : .beige200) + voteCategoryBadge(category) } Spacer(minLength: 0) if !isFinalVote, let commentCount = store.voteCommentCountLabel { - Label(commentCount, systemImage: "bubble.right") - .pretendardFont(.medium11) - .foregroundStyle(.gray500) - .padding(.horizontal, 5) - .padding(.vertical, 3) - .overlay(Rectangle().stroke(.beige500, lineWidth: 1)) + voteCommentCountBadge(commentCount) } } Text(store.displayTitle) - .pretendardFont(.bold24) + .pretendardFont(.bold24, lineHeightMultiple: 1.3) .foregroundStyle(isFinalVote ? .white : .gray800) .fixedSize(horizontal: false, vertical: true) + .padding(.top, 20) Text(store.displaySummary) - .pretendardFont(.bodyMedium) - .foregroundStyle(isFinalVote ? .beige200 : .gray500) + .pretendardFont(.bodyMedium, lineHeightMultiple: 1.4) + .foregroundStyle(isFinalVote ? .gray300 : .gray400) .fixedSize(horizontal: false, vertical: true) + .padding(.top, 12) } .frame(maxWidth: .infinity, alignment: .leading) } + func voteCategoryBadge(_ category: String) -> some View { + Text(category) + .pretendardFont(.semiBold12) + .foregroundStyle(.badgeFilledText) + .lineLimit(1) + .frame(height: 16) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.badgeFilledBackground, in: RoundedRectangle(cornerRadius: 2)) + } + + @ViewBuilder + func voteCommentCountBadge(_ count: String) -> some View { + HStack(spacing: 2) { + Image(asset: .commentMessage) + .resizable() + .frame(width: 16, height: 16) + .accessibilityHidden(true) + Text(count) + .pretendardFont(.labelSmall) + .fixedSize() + } + .foregroundStyle(.gray300) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(.white, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .strokeBorder(.gray100, lineWidth: 1) + } + } + @ViewBuilder func optionSection(isPreVoteDraft: Bool = false) -> some View { ZStack { @@ -223,11 +255,10 @@ private extension ClassBattleFlowView { if store.options.count == 2 { Text("VS") - .pretendardFont(.bold11) - .foregroundStyle(.neutral800) + .pretendardFont(.semiBold11) + .foregroundStyle(.gray800) .frame(width: 28, height: 28) .background(.secondary200, in: Circle()) - .overlay(Circle().stroke(.beige50, lineWidth: 1.5)) .allowsHitTesting(false) } } @@ -238,20 +269,21 @@ private extension ClassBattleFlowView { _ option: ClassBattleOption, isPreVoteDraft: Bool ) -> some View { + let isSelected = (isPreVoteDraft ? preVoteDraftOptionID : store.selectedOptionID) == option.id Button { if isPreVoteDraft { preVoteDraftOptionID = option.id } else { send(.optionTapped(option.id)) } } label: { - VStack(spacing: 8) { + VStack(spacing: 0) { Group { if let url = option.imageURL { AsyncImage(url: url) { image in image.resizable().scaledToFill() } placeholder: { - Circle().fill(.beige400) + Circle().fill(.beige600) } } else { - Circle().fill(.beige400) + Circle().fill(.beige600) .overlay { Text(option.label ?? "") .pretendardFont(.semiBold13) @@ -262,36 +294,36 @@ private extension ClassBattleFlowView { .clipShape(Circle()) Text(option.title) .pretendardFont(.headingSmall) - .foregroundStyle(.neutral600) + .foregroundStyle(.gray800) .lineLimit(2) .multilineTextAlignment(.center) + .frame(minHeight: 20) + .padding(.top, 12) Text(option.representative) - .pretendardFont(.labelSmall) + .pretendardFont(.labelSmall, lineHeightMultiple: 1.4) .foregroundStyle(.neutral300) .lineLimit(1) + .padding(.top, 2) } .frame(maxWidth: .infinity) - .frame(height: 106) + .frame(height: 109) .background( - .beige300, - in: RoundedRectangle(cornerRadius: 3) + isSelected ? Color.beige400 : .beige300, + in: RoundedRectangle(cornerRadius: 2) ) .overlay { - RoundedRectangle(cornerRadius: 3) - .strokeBorder( - (isPreVoteDraft ? preVoteDraftOptionID : store.selectedOptionID) == option.id - ? (isFinalVote ? Color.secondary500 : .beige700) - : .beige500, - lineWidth: 1 - ) + RoundedRectangle(cornerRadius: 2) + .strokeBorder(isSelected ? Color.secondary500 : .beige600, lineWidth: 1) } + .opacity(isSelected ? 1 : 0.88) } + .accessibilityAddTraits(isSelected ? .isSelected : []) .buttonStyle(.plain) .disabled(!store.canParticipate) } @ViewBuilder - func voteFooter() -> some View { + func voteFooter(bottomPadding: CGFloat) -> some View { VStack(spacing: 8) { if let error = store.errorMessage { Text(error) @@ -308,12 +340,19 @@ private extension ClassBattleFlowView { deadline, systemImage: "clock" ) - .pretendardFont(.regular13) - .foregroundStyle(isFinalVote ? .beige200 : .gray500) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) + .foregroundStyle(.gray300) } } .frame(maxWidth: .infinity) - .padding(.bottom, isFinalVote ? 10 : 20) + .padding(.bottom, bottomPadding) + } + + func voteFooterBottomPadding(hasBottomSafeArea: Bool) -> CGFloat { + if isFinalVote { return hasBottomSafeArea ? 6 : 10 } + // 홈 인디케이터가 있는 기기에서는 Figma처럼 마지막 줄인 마감 문구만 그 영역에 10pt 걸친다. + guard hasBottomSafeArea, store.voteDeadlineLabel != nil else { return 20 } + return -10 } @ViewBuilder @@ -340,12 +379,12 @@ private extension ClassBattleFlowView { LazyVStack(alignment: .leading, spacing: 6) { ForEach(rows) { row in transcriptRow(row) - .padding(.top, row.hasTopSpacing ? 16 : 0) + .padding(.top, row.hasTopSpacing ? 14 : 0) .id(row.id) } } .padding(.horizontal, 16) - .padding(.top, 24) + .padding(.top, 8) .padding(.bottom, 40) } .onChange(of: store.currentTime) { _, _ in @@ -356,6 +395,9 @@ private extension ClassBattleFlowView { } } } + if store.pendingCheckpointID != nil { + midVoteChoiceSection() + } audioFooter() } .screenBackground() @@ -365,6 +407,50 @@ private extension ClassBattleFlowView { } } + @ViewBuilder + func midVoteChoiceSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("선택의 시간") + .pretendardFont(.headingMedium) + .foregroundStyle(.neutral900) + ForEach(store.midVoteOptions) { option in + Button { + send(.midVoteOptionTapped(option.nextNodeId)) + } label: { + VStack(alignment: .leading, spacing: 4) { + if let title = option.title, !title.isEmpty, title != option.label { + Text(option.label) + .pretendardFont(.labelSmall) + .foregroundStyle(.neutral600) + } + Text(option.title.flatMap { $0.isEmpty ? nil : $0 } ?? option.label) + .pretendardFont(.bodyMedium) + .foregroundStyle(.neutral900) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + store.selectedMidOptionID == option.nextNodeId ? .beige300 : .white, + in: RoundedRectangle(cornerRadius: 4) + ) + .overlay { + RoundedRectangle(cornerRadius: 4) + .strokeBorder( + store.selectedMidOptionID == option.nextNodeId ? .primary500 : .beige600, + lineWidth: 1 + ) + } + } + .buttonStyle(.plain) + } + Button("입장 선택하기") { send(.confirmMidVoteTapped) } + .ctaButtonStyle(.primary, size: .large, height: 52) + .disabled(!store.canConfirmMidVote) + } + .padding(16) + .background(.beige200) + } + @ViewBuilder func preVoteEditor() -> some View { VStack(alignment: .leading, spacing: 20) { @@ -403,8 +489,8 @@ private extension ClassBattleFlowView { .frame(maxWidth: .infinity) .padding(.vertical, 8) } else { - let bubbleBackground: Color = line.side == "B" ? .beige500 : .white - let bubbleBorder: Color = line.side == "B" ? .beige700 : .beige500 + let bubbleBackground: Color = line.side == "B" ? .beige400 : .white + let bubbleBorder: Color = .beige600 HStack(alignment: .top, spacing: 6) { if line.side != "B" { transcriptAvatar(row) @@ -412,16 +498,16 @@ private extension ClassBattleFlowView { VStack(alignment: line.side == "B" ? .trailing : .leading, spacing: 6) { if row.showsHeader { Text(line.speakerName) - .pretendardFont(.headingSmall) - .foregroundStyle(.gray400) + .pretendardFont(.headingSmall, lineHeightMultiple: 1.4) + .foregroundStyle(.gray800) } Text(line.text) - .pretendardFont(.labelSmall) - .foregroundStyle(.gray700) - .lineSpacing(4.8) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) + .foregroundStyle(.gray400) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) + .padding(.horizontal, 8) + .padding(.vertical, line.side == "B" ? 8 : 6) .pickeCard(bubbleBackground, border: bubbleBorder) .overlay(alignment: line.side == "B" ? .leading : .trailing) { if row.isActive && store.isPlaying { @@ -470,22 +556,26 @@ private extension ClassBattleFlowView { .pretendardFont(.regular13) .foregroundStyle(.primary500) Spacer() - Button("다시 시도") { send(.retryAudioTapped) } + if store.pendingCheckpointID == nil { + Button("다시 시도") { send(.retryAudioTapped) } + } } } - audioProgressBar() - AudioPlayerControlView( - isPlaying: .constant(store.isPlaying), - onBackward: { send(.seekBy(-15)) }, - onTogglePlay: { send(.playbackTapped) }, - onForward: { send(.seekBy(15)) }, - onReplay: { send(.replayTapped) }, - isReplayEnabled: store.hasListened, - tint: .neutral900, - playbackRateTitle: store.playbackRateTitle, - onPlaybackRateSelected: { send(.playbackRateSelected($0)) } - ) - .disabled(!store.isAudioReady) + VStack(spacing: 0) { + audioProgressBar() + AudioPlayerControlView( + isPlaying: .constant(store.isPlaying), + onBackward: { send(.seekBy(-15)) }, + onTogglePlay: { send(.playbackTapped) }, + onForward: { send(.seekBy(15)) }, + onReplay: { send(.replayTapped) }, + isReplayEnabled: store.hasListened, + tint: .neutral900, + playbackRateTitle: store.playbackRateTitle, + onPlaybackRateSelected: { send(.playbackRateSelected($0)) } + ) + .disabled(!store.isAudioReady || store.pendingCheckpointID != nil) + } if store.hasListened { Button(store.buttonTitle) { send(.submitTapped) } .ctaButtonStyle(.primary, size: .large, height: 52) @@ -493,28 +583,27 @@ private extension ClassBattleFlowView { } } .padding(.horizontal, 24) - .padding(.top, 16) + .padding(.top, 24) .padding(.bottom, 8) .background(.white) - .topDivider(.beige600) } @ViewBuilder func audioProgressBar() -> some View { - VStack(spacing: 8) { + VStack(spacing: 0) { GeometryReader { geometry in ZStack(alignment: .leading) { - Capsule().fill(.beige600).frame(height: 4) + Capsule().fill(.gray300).frame(height: 3) Capsule() .fill(.primary500) - .frame(width: geometry.size.width * store.audioProgress, height: 4) + .frame(width: geometry.size.width * store.audioProgress, height: 3) Circle() .fill(.primary500) - .frame(width: 16, height: 16) - .offset(x: max(0, geometry.size.width * store.audioProgress - 8)) + .frame(width: 11, height: 11) + .offset(x: max(0, geometry.size.width * store.audioProgress - 5.5)) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) + .contentShape(Rectangle().inset(by: -8)) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in @@ -525,13 +614,13 @@ private extension ClassBattleFlowView { ) .allowsHitTesting(store.hasListened) } - .frame(height: 16) + .frame(height: 11) HStack { Text(store.currentTimeText) Spacer() Text(store.audioDurationText) } - .pretendardFont(.medium11) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) .foregroundStyle(.neutral300) } } @@ -545,12 +634,16 @@ private extension ClassBattleFlowView { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { perspectiveSortRow() + if let row = store.hiddenMyPerspectiveRow { + perspectiveCard(row) + } ForEach(store.perspectiveRows) { row in perspectiveCard(row) } if store.isLoadingPerspectives { SkeletonView(.rect).frame(height: 90) - } else if store.perspectives.isEmpty && store.perspectiveErrorMessage == nil { + } else if store.perspectives.isEmpty && store.perspectiveErrorMessage == nil + && store.hiddenMyPerspectiveRow == nil { Text("아직 등록된 의견이 없어요.") .pretendardFont(.bodyMedium) .foregroundStyle(.gray500) @@ -583,6 +676,7 @@ private extension ClassBattleFlowView { } } .background(.white) + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) } @ViewBuilder @@ -595,36 +689,40 @@ private extension ClassBattleFlowView { } } .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 16) + .padding(.top, 8) + .padding(.bottom, 8) .frame(maxWidth: .infinity) - .background(.white) + .background(.beige200) } @ViewBuilder func voteStatsSummary(_ stats: ClassVoteStats) -> some View { let rows = store.voteStatsOptionRows - HStack(alignment: .center, spacing: 12) { + HStack(alignment: .top, spacing: 0) { if let first = rows.first { voteStatsOption(first) } - VStack(spacing: 20) { + // Figma는 높이 76 열에서 막대 영역(35)을 아래에 붙여 배지와 11 떨어진다. + VStack(spacing: 11) { if let opinionChangeLabel = store.opinionChangeLabel { Label( opinionChangeLabel, systemImage: "lightbulb" ) - .pretendardFont(.medium11) + .pretendardFont(.semiBold12) .foregroundStyle(.primary500) - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background(.beige200) + .padding(.horizontal, 4) + .frame(height: 20) + .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) .fixedSize() } else { Color.clear.frame(height: 20) } voteRatioBar(stats) + .padding(.horizontal, 8) + .frame(height: 35) } + .padding(.top, 10) .frame(maxWidth: .infinity) if rows.count > 1 { voteStatsOption(rows[1]) @@ -635,10 +733,10 @@ private extension ClassBattleFlowView { @ViewBuilder func voteRatioBar(_ stats: ClassVoteStats) -> some View { GeometryReader { geometry in - Capsule() - .fill(.beige300) + RoundedRectangle(cornerRadius: 2) + .fill(.beige500) .overlay(alignment: .leading) { - Capsule() + RoundedRectangle(cornerRadius: 2) .fill(.primary500) .frame( width: geometry.size.width * CGFloat(min(max(stats.options.first?.ratio ?? 0, 0), 100) / 100) @@ -651,7 +749,7 @@ private extension ClassBattleFlowView { @ViewBuilder func voteStatsOption(_ row: ClassVoteStatsOptionRow) -> some View { let option = row.option - VStack(spacing: 2) { + VStack(spacing: 0) { AsyncImage(url: option.imageURL) { image in image.resizable().scaledToFill() } placeholder: { @@ -660,17 +758,18 @@ private extension ClassBattleFlowView { .foregroundStyle(.gray800) } .frame(width: 40, height: 40) - .background(.beige200, in: Circle()) + .background(.beige600, in: Circle()) .clipShape(Circle()) Text(option.title) - .pretendardFont(.medium11) - .foregroundStyle(.gray800) + .pretendardFont(.medium11, lineHeightMultiple: 1.4) + .foregroundStyle(.gray500) .lineLimit(1) + .padding(.top, 4) Text(row.percentageLabel) - .pretendardFont(.semiBold13) - .foregroundStyle(.gray800) + .pretendardFont(.semiBold12, lineHeightMultiple: 1.4) + .foregroundStyle(.gray500) } - .frame(width: 64) + .frame(width: 70) } @ViewBuilder @@ -681,7 +780,7 @@ private extension ClassBattleFlowView { perspectiveFilter(title: option.title, optionID: option.id) } } - .background(.white) + .background(.beige200) } @ViewBuilder @@ -691,14 +790,16 @@ private extension ClassBattleFlowView { ) -> some View { Button { send(.perspectiveOptionTapped(optionID)) } label: { Text(title) - .pretendardFont(.semiBold13) + .pretendardFont(.labelMedium) .lineLimit(1) - .foregroundStyle(store.selectedPerspectiveOptionID == optionID ? .primary500 : .gray500) + .foregroundStyle(store.selectedPerspectiveOptionID == optionID ? .primary500 : .gray300) .frame(maxWidth: .infinity) - .padding(.vertical, 14) + .frame(height: 44) .overlay(alignment: .bottom) { if store.selectedPerspectiveOptionID == optionID { Rectangle().fill(.primary500).frame(height: 2) + } else { + Rectangle().fill(.gray100).frame(height: 1) } } } @@ -713,7 +814,8 @@ private extension ClassBattleFlowView { Spacer() } .padding(.horizontal, 16) - .padding(.vertical, 16) + .padding(.top, 16) + .padding(.bottom, 6) } @ViewBuilder @@ -723,11 +825,10 @@ private extension ClassBattleFlowView { ) -> some View { Button { send(.perspectiveSortTapped(value)) } label: { Text(title) - .pretendardFont(.medium11) + .pretendardFont(.medium13) .foregroundStyle(store.perspectiveSort == value ? .white : .primary500) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(store.perspectiveSort == value ? Color.primary500 : Color.white, in: RoundedRectangle(cornerRadius: 2)) + .frame(width: 58, height: 30) + .background(store.perspectiveSort == value ? Color.primary500 : .primary50, in: RoundedRectangle(cornerRadius: 2)) .overlay { RoundedRectangle(cornerRadius: 2) .stroke(.primary500, lineWidth: 1) @@ -740,56 +841,100 @@ private extension ClassBattleFlowView { func perspectiveCard(_ row: ClassPerspectiveRow) -> some View { let perspective = row.perspective VStack(alignment: .leading, spacing: 8) { - perspectiveHeader(perspective) + perspectiveHeader(row) VStack(alignment: .leading, spacing: 4) { Button { send(.perspectiveTapped(perspective.id)) } label: { Text(perspective.content) - .pretendardFont(.regular13) - .foregroundStyle(.gray500) - .lineSpacing(5) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) + .foregroundStyle(.gray300) .lineLimit(3) + .padding(.horizontal, 2) .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) + .disabled(row.statusBadgeTitle != nil) Button("자세히 보기") { send(.perspectiveTapped(perspective.id)) } - .pretendardFont(.labelSmall) + .pretendardFont(.labelSmall, lineHeightMultiple: 1.4) .foregroundStyle(.gray200) + .padding(.horizontal, 2) .buttonStyle(.plain) + .disabled(row.statusBadgeTitle != nil) } perspectiveActions(row) } .padding(.horizontal, 16) - .padding(.vertical, 18) + .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) - .background(.white) + .background(row.isPendingReview ? Color.secondary50 : row.isRejected ? .primary50 : .white) .overlay(alignment: .bottom) { - Rectangle().fill(.beige200).frame(height: 1) + Rectangle().fill(.beige600).frame(height: 1) + } + .overlay(alignment: .bottomTrailing) { + if store.menuTargetPerspectiveID == perspective.id { + perspectiveMenu(row) + .padding(.bottom, 40) + .padding(.trailing, 16) + } + } + .zIndex(store.menuTargetPerspectiveID == perspective.id ? 1 : 0) + } + + /// 더보기 메뉴 — Android 순서(내 의견: 삭제·수정, 거절: 수정, 남 의견: 신고). + @ViewBuilder + func perspectiveMenu(_ row: ClassPerspectiveRow) -> some View { + let id = row.perspective.id + VStack(alignment: .trailing, spacing: 8) { + switch row.menuKind { + case .report: + PickeActionPill(title: "신고", systemImage: "light.beacon.max.fill") { send(.perspectiveReportTapped(id)) } + case .editOnly: + PickeActionPill(title: "수정", systemImage: "pencil") { send(.perspectiveEditTapped(id)) } + case .editAndDelete: + PickeActionPill(title: "삭제", systemImage: "trash") { send(.perspectiveDeleteTapped(id)) } + PickeActionPill(title: "수정", systemImage: "pencil") { send(.perspectiveEditTapped(id)) } + case .hidden: + EmptyView() + } } } @ViewBuilder - func perspectiveHeader(_ perspective: ClassPerspectiveItem) -> some View { + func perspectiveHeader(_ row: ClassPerspectiveRow) -> some View { + let perspective = row.perspective HStack(spacing: 8) { PickeAvatarView( imageURL: perspective.author.characterImageURL?.absoluteString, fallback: perspective.author.nickname, - size: 40 + size: 36 ) VStack(alignment: .leading, spacing: 2) { Text(perspective.author.nickname) - .pretendardFont(.semiBold13) + .pretendardFont(.semiBold12, lineHeightMultiple: 1.4) .foregroundStyle(.gray800) - Text(perspective.createdAt, style: .relative) - .pretendardFont(.regular11) - .foregroundStyle(.gray500) + Text(row.createdAtLabel) + .pretendardFont(.labelXSmall, lineHeightMultiple: 1.4) + .foregroundStyle(.gray300) } Spacer() - Text(perspective.option.title) - .pretendardFont(.medium11) - .foregroundStyle(.primary500) - .padding(.horizontal, 5) - .padding(.vertical, 3) - .background(.beige200) + if let statusTitle = row.statusBadgeTitle { + Text(statusTitle) + .pretendardFont(.semiBold12) + .foregroundStyle(.white) + .lineLimit(1) + .frame(height: 16) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(row.isRejected ? Color.primary400 : .secondary500, in: RoundedRectangle(cornerRadius: 2)) + } else { + Text(perspective.option.title) + .pretendardFont(.semiBold12) + .foregroundStyle(.badgeFilledText) + .lineLimit(1) + .frame(height: 16) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.badgeFilledBackground, in: RoundedRectangle(cornerRadius: 2)) + } } } @@ -806,7 +951,7 @@ private extension ClassBattleFlowView { Text(row.likeCountLabel) .pretendardFont(.labelSmall) } - .foregroundStyle(perspective.isLiked ? .primary500 : .gray500) + .foregroundStyle(perspective.isLiked ? .primary500 : .gray300) } .disabled(perspective.isMyPerspective != false) Button { send(.perspectiveTapped(perspective.id)) } label: { @@ -818,16 +963,34 @@ private extension ClassBattleFlowView { Text(row.commentCountLabel) .pretendardFont(.labelSmall) } - .foregroundStyle(.gray500) + .foregroundStyle(.gray300) } + // Android처럼 검수 중·거절된 의견은 상세로 이동하지 않는다. + .disabled(row.statusBadgeTitle != nil) Spacer() + if row.menuKind.hasMenu { + Button { send(.perspectiveMoreTapped(perspective.id)) } label: { + Image(systemName: "ellipsis") + .resizable() + .scaledToFit() + .frame(width: 15, height: 3) + .frame(width: 24, height: 24) + .contentShape(Rectangle().inset(by: -10)) + } + .foregroundStyle(.gray300) + .accessibilityLabel("더보기") + } } + .frame(height: 24) .buttonStyle(.plain) } @ViewBuilder func composerBar() -> some View { VStack(alignment: .leading, spacing: 0) { + if store.editingPerspectiveID != nil { + editingBanner() + } if let error = store.errorMessage { Text(error) .pretendardFont(.regular13) @@ -835,30 +998,55 @@ private extension ClassBattleFlowView { .padding(.horizontal, 16) .padding(.top, 12) } - HStack(spacing: 12) { + HStack(spacing: 8) { TextField( - "의견을 입력하세요", + "의견을 남겨보세요...", text: Binding( get: { store.draft }, set: { send(.draftChanged($0)) } - ) + ), + prompt: Text("의견을 남겨보세요...").foregroundStyle(.gray300) ) - .pretendardFont(.bodyMedium) + .pretendardFont(.medium13) .padding(.horizontal, 12) - .frame(height: 44) - .background(.white, in: RoundedRectangle(cornerRadius: 4)) + .frame(height: 52) + .background(.white, in: RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .strokeBorder(.beige600, lineWidth: 1) + } Button { send(.submitTapped) } label: { Image(systemName: "paperplane.fill") .foregroundStyle(.white) - .frame(width: 44, height: 44) + .frame(width: 40, height: 40) .background(.primary500, in: Circle()) + .contentShape(Rectangle().inset(by: -2)) } .buttonStyle(.plain) .disabled(!store.canSubmit) } .padding(16) } - .background(.beige200) + .background(.beige600) + .overlay(alignment: .top) { + Rectangle().fill(.beige700).frame(height: 1) + } + } + + @ViewBuilder + func editingBanner() -> some View { + HStack { + Text("의견 수정 중") + .pretendardFont(.semiBold12) + .foregroundStyle(.primary500) + Spacer() + Button("취소") { send(.cancelEditTapped) } + .pretendardFont(.medium13) + .foregroundStyle(.gray400) + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.top, 12) } var plainNavigationBar: some View { @@ -876,12 +1064,12 @@ private extension ClassBattleFlowView { ) { Button { send(.reportTapped) } label: { Label("리포트", systemImage: "list.bullet.rectangle") - .pretendardFont(.medium11) + .pretendardFont(.bold11) } .buttonStyle(.plain) .foregroundStyle(.primary500) } - .background(.beige50) + .background(.beige200) .accessibilityAction(named: "뒤로 가기") { send(.backTapped) } } } diff --git a/Projects/Feature/Class/Sources/ClassIntro/Model/ClassCreateTicketNotice.swift b/Projects/Feature/Class/Sources/ClassIntro/Model/ClassCreateTicketNotice.swift new file mode 100644 index 00000000..f43a8b1e --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassIntro/Model/ClassCreateTicketNotice.swift @@ -0,0 +1,23 @@ +// +// ClassCreateTicketNotice.swift +// Class +// +// Created by Picke on 11/4/26. +// + +import PickeSharedUI + +/// 클래스 만들기 전 이용권 확인 결과로 띄우는 안내(Android `ClassTicketDialog`). +public enum ClassCreateTicketNotice: Equatable, Sendable { + /// 등록된 이용권이 없음(`TICKET_404`). 닫기만 한다. + case required + /// 사용 중이던 이용권이 만료됨. 등록하기로 이용권 등록 화면에 간다. + case expired + + public var alert: CustomAlertState { + switch self { + case .required: return .classTicketRequired() + case .expired: return .classTicketExpired() + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift b/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift index a7c656a3..0f4a599a 100644 --- a/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift +++ b/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift @@ -5,6 +5,7 @@ // Created by Picke on 10/11/26. // +import ClassDomainInterface import ComposableArchitecture import Foundation import PickeSharedUI @@ -16,6 +17,9 @@ public struct ClassIntroFeature { @ObservableState public struct State: Equatable { @Presents public var customAlert: CustomAlertState? + public var ticketNotice: ClassCreateTicketNotice? + public var isCheckingTicket = false + public var toastMessage: String? public init() {} } @@ -23,6 +27,7 @@ public struct ClassIntroFeature { public enum Action: ViewAction { case view(View) case customAlert(PresentationAction) + case inner(InnerAction) case delegate(DelegateAction) } @@ -33,6 +38,12 @@ public struct ClassIntroFeature { case myClassesTapped case createTapped case ticketTapped + case toastShown + } + + @CasePathable + public enum InnerAction: Equatable { + case ticketResponse(Result) } @CasePathable @@ -42,8 +53,15 @@ public struct ClassIntroFeature { case myClasses case create case ticket + case registerTicket } + public enum CancelID: Hashable, Sendable { + case ticketCheck + } + + @Dependency(\.classTicketUseCase) private var ticketUseCase + public var body: some Reducer { Reduce { state, action in switch action { @@ -53,6 +71,9 @@ public struct ClassIntroFeature { case let .customAlert(alertAction): return handleCustomAlertAction(state: &state, action: alertAction) + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + case .delegate: return .none } @@ -71,8 +92,12 @@ extension ClassIntroFeature { action: PresentationAction ) -> Effect { switch action { - case .presented(.confirmTapped), .presented(.cancelTapped), .dismiss: - state.customAlert = nil + case .presented(.confirmTapped): + let notice = state.ticketNotice + clearTicketNotice(state: &state) + return notice == .expired ? .send(.delegate(.registerTicket)) : .none + case .presented(.cancelTapped), .dismiss: + clearTicketNotice(state: &state) return .none case .presented: return .none @@ -85,19 +110,81 @@ extension ClassIntroFeature { ) -> Effect { switch action { case .backTapped: - return .send(.delegate(.backToHome)) + return cancelTicketCheck(state: &state, then: .send(.delegate(.backToHome))) case .joinTapped: - return .send(.delegate(.join)) + return cancelTicketCheck(state: &state, then: .send(.delegate(.join))) case .myClassesTapped: - return .send(.delegate(.myClasses)) + return cancelTicketCheck(state: &state, then: .send(.delegate(.myClasses))) case .createTapped: - return .send(.delegate(.create)) + // Android와 같이 만들기 전에 내 이용권 상태를 확인한다. 확인 중 다시 누르면 무시한다. + guard !state.isCheckingTicket else { return .none } + state.isCheckingTicket = true + return .run { [ticketUseCase] send in + let result: Result = await Result { + try await ticketUseCase.fetchMyTicket() + } + .mapError { ($0 as? ClassTicketError) ?? .network($0.localizedDescription) } + await send(.inner(.ticketResponse(result))) + } + .cancellable(id: CancelID.ticketCheck, cancelInFlight: true) case .ticketTapped: - return .send(.delegate(.ticket)) + return cancelTicketCheck(state: &state, then: .send(.delegate(.ticket))) + + case .toastShown: + state.toastMessage = nil + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .ticketResponse(result): + guard state.isCheckingTicket else { return .none } + state.isCheckingTicket = false + switch result { + case let .success(ticket): + switch ticket.status { + case .active: + return .send(.delegate(.create)) + case .expired: + presentTicketNotice(.expired, state: &state) + } + case .failure(.notFound): + presentTicketNotice(.required, state: &state) + case .failure: + state.toastMessage = "정보를 불러오지 못했어요" + } + return .none } } + + /// 확인 중 다른 이동을 하면 늦은 응답이 화면을 쌓지 않도록 확인을 취소한다. + private func cancelTicketCheck( + state: inout State, + then effect: Effect + ) -> Effect { + guard state.isCheckingTicket else { return effect } + state.isCheckingTicket = false + return .merge(.cancel(id: CancelID.ticketCheck), effect) + } + + private func presentTicketNotice( + _ notice: ClassCreateTicketNotice, + state: inout State + ) { + state.ticketNotice = notice + state.customAlert = notice.alert + } + + private func clearTicketNotice(state: inout State) { + state.ticketNotice = nil + state.customAlert = nil + } } diff --git a/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift b/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift index d056b99a..108f68e1 100644 --- a/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift +++ b/Projects/Feature/Class/Sources/ClassIntro/View/ClassIntroView.swift @@ -42,6 +42,11 @@ public struct ClassIntroView: View { .background(backgroundImage()) .toolbar(.hidden, for: .navigationBar) .customAlert($store.scope(state: \.customAlert, action: \.customAlert)) + .onChange(of: store.toastMessage, initial: true) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } } } diff --git a/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift b/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift index a7819431..63e7f5ea 100644 --- a/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift +++ b/Projects/Feature/Class/Sources/ClassMyOpinions/Reducer/ClassMyOpinionsFeature.swift @@ -15,10 +15,20 @@ public struct ClassMyOpinionsFeature { @ObservableState public struct State: Equatable { + /// 레거시 리포트 경로(서버 결과가 없을 때) 코디네이터가 채우는 스냅샷. public var opinions: [ClassReportOpinion] = [] + /// 서버 경로: 클래스에서 내가 쓴 공개 의견(Android `남긴 댓글`). + public var perspectiveOpinions: [ClassReportOpinion] = [] + /// 서버 경로: 내 활동 API의 내가 남긴 댓글(Android `남긴 대댓글`). + public var replyOpinions: [ClassReportOpinion] = [] public var classID: Int? public var isLoading = false public var errorMessage: String? + public var myDisplayName: String? + public var nextCursor: String? + public var hasNext = false + public var isLoadingMore = false + public var toastMessage: String? public var comments: [ClassMyOpinionRow] { rows.filter { $0.opinion.kind == .comment } @@ -32,10 +42,18 @@ public struct ClassMyOpinionsFeature { rows.filter { $0.opinion.kind == .unspecified } } - public var isEmpty: Bool { opinions.isEmpty } + public var isEmpty: Bool { + opinions.isEmpty && perspectiveOpinions.isEmpty && replyOpinions.isEmpty + } + + public var canLoadMoreReplies: Bool { + hasNext && nextCursor != nil && !isLoadingMore + } private var rows: [ClassMyOpinionRow] { - opinions.enumerated().map { ClassMyOpinionRow(index: $0.offset, opinion: $0.element) } + (opinions + perspectiveOpinions + replyOpinions) + .enumerated() + .map { ClassMyOpinionRow(index: $0.offset, opinion: $0.element) } } public init() {} @@ -53,16 +71,22 @@ public struct ClassMyOpinionsFeature { case onAppear case retryTapped case backTapped + case repliesEndReached + case toastShown } @CasePathable public enum AsyncAction: Equatable { case fetch + case fetchReplies + case fetchReplyPage(cursor: String?) } @CasePathable public enum InnerAction: Equatable { case perspectivesResponse(Result<[ClassPerspectiveItem], ClassBattleError>) + case myDisplayNameResponse(Result) + case replyPageResponse(cursor: String?, Result) } @CasePathable @@ -72,39 +96,127 @@ public struct ClassMyOpinionsFeature { public enum CancelID: Hashable, Sendable { case fetch + case replies + case moreReplies } + static let loadFailedMessage = "정보를 불러오지 못했어요" + @Dependency(\.classBattleUseCase) private var classUseCase + @Dependency(\.classMemberUseCase) private var memberUseCase + @Dependency(\.classResultUseCase) private var resultUseCase public var body: some Reducer { Reduce { state, action in switch action { - case .view(.onAppear): - guard state.classID != nil, !state.isLoading, state.opinions.isEmpty, state.errorMessage == nil else { return .none } - return .send(.async(.fetch)) - case .view(.retryTapped): - guard state.classID != nil, !state.isLoading else { return .none } - return .send(.async(.fetch)) - case .view(.backTapped): - return .merge(.cancel(id: CancelID.fetch), .send(.delegate(.dismiss))) - case .async(.fetch): - guard let classID = state.classID else { return .none } - state.isLoading = true - state.errorMessage = nil - 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))) + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + case .delegate: + return .none + } + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.classID != nil, + !state.isLoading, + state.perspectiveOpinions.isEmpty, + state.replyOpinions.isEmpty, + state.errorMessage == nil else { return .none } + return .merge(.send(.async(.fetch)), .send(.async(.fetchReplies))) + case .retryTapped: + guard state.classID != nil, !state.isLoading else { return .none } + state.perspectiveOpinions = [] + state.replyOpinions = [] + state.nextCursor = nil + state.hasNext = false + state.isLoadingMore = false + return .concatenate( + .cancel(id: CancelID.replies), + .cancel(id: CancelID.moreReplies), + .merge(.send(.async(.fetch)), .send(.async(.fetchReplies))) + ) + case .backTapped: + return .merge( + .cancel(id: CancelID.fetch), + .cancel(id: CancelID.replies), + .cancel(id: CancelID.moreReplies), + .send(.delegate(.dismiss)) + ) + case .repliesEndReached: + guard state.canLoadMoreReplies else { return .none } + return .send(.async(.fetchReplyPage(cursor: state.nextCursor))) + case .toastShown: + state.toastMessage = nil + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetch: + guard let classID = state.classID else { return .none } + state.isLoading = true + state.errorMessage = nil + 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))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + case .fetchReplies: + guard let classID = state.classID else { return .none } + return .run { [memberUseCase] send in + let result = await Result { + let list = try await memberUseCase.fetchMembers(classID: classID) + return list.members.first { $0.id == list.currentMemberID }?.name ?? "" + } + .mapError(ClassError.from) + await send(.inner(.myDisplayNameResponse(result))) + } + .cancellable(id: CancelID.replies, cancelInFlight: true) + case let .fetchReplyPage(cursor): + guard let classID = state.classID else { return .none } + if cursor != nil { state.isLoadingMore = true } + return .run { [resultUseCase] send in + let result = await Result { + try await resultUseCase.fetchMyActivity(roomID: classID, cursor: cursor) } - .cancellable(id: CancelID.fetch, cancelInFlight: true) - case let .inner(.perspectivesResponse(result)): - state.isLoading = false - switch result { - case let .success(items): - state.opinions = items.map { + .mapError(ClassError.from) + await send(.inner(.replyPageResponse(cursor: cursor, result))) + } + .cancellable(id: cursor == nil ? CancelID.replies : CancelID.moreReplies, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .perspectivesResponse(result): + state.isLoading = false + switch result { + case let .success(items): + // Android `PerspectiveStatus.from`처럼 알 수 없는 상태는 공개로 보고 검수 중·거절만 뺀다. + state.perspectiveOpinions = items + .filter { !["PENDING", "REJECTED"].contains($0.status?.uppercased() ?? "") } + .map { ClassReportOpinion( id: String($0.id), - kind: .unspecified, + kind: .comment, body: $0.content, stance: $0.option.stance ?? $0.option.title, likeCount: $0.likeCount, @@ -112,13 +224,44 @@ public struct ClassMyOpinionsFeature { authorName: $0.author.nickname ) } - case let .failure(error): - state.errorMessage = error.message - } - return .none - case .delegate: + case let .failure(error): + state.errorMessage = error.message + } + return .none + case let .myDisplayNameResponse(result): + switch result { + case let .success(name): + state.myDisplayName = name + return .send(.async(.fetchReplyPage(cursor: nil))) + case .failure: + state.toastMessage = Self.loadFailedMessage return .none } + case let .replyPageResponse(cursor, result): + state.isLoadingMore = false + switch result { + case let .success(activity): + let name = state.myDisplayName.flatMap { $0.isEmpty ? nil : $0 } ?? "나" + let page = activity.comments.map { comment in + ClassReportOpinion( + id: "reply-\(comment.id)", + kind: .reply, + body: comment.content, + likeCount: comment.likeCount, + authorName: name, + parent: ClassReportReplyContext(authorName: nil, stance: nil, body: comment.perspectiveTitle) + ) + } + // 첫 페이지는 교체하고 다음 페이지만 이어 붙여 다시 나타날 때 중복되지 않게 한다. + state.replyOpinions = cursor == nil ? page : state.replyOpinions + page + state.nextCursor = activity.nextCursor + state.hasNext = activity.hasNext + case .failure: + state.toastMessage = Self.loadFailedMessage + // Android `RequestFailed`처럼 다음 페이지 실패 시 더 불러오기를 멈춰 무한 로딩·반복 토스트를 막는다. + if cursor != nil { state.hasNext = false } + } + return .none } } } diff --git a/Projects/Feature/Class/Sources/ClassMyOpinions/View/ClassMyOpinionsView.swift b/Projects/Feature/Class/Sources/ClassMyOpinions/View/ClassMyOpinionsView.swift index c8ba407f..ee1163ea 100644 --- a/Projects/Feature/Class/Sources/ClassMyOpinions/View/ClassMyOpinionsView.swift +++ b/Projects/Feature/Class/Sources/ClassMyOpinions/View/ClassMyOpinionsView.swift @@ -28,6 +28,11 @@ public struct ClassMyOpinionsView: View { .screenBackground() .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } + .onChange(of: store.toastMessage) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } } } @@ -77,13 +82,16 @@ private extension ClassMyOpinionsView { } else if store.isEmpty { emptyState } else { - VStack(alignment: .leading, spacing: 20) { + LazyVStack(alignment: .leading, spacing: 20) { if !store.comments.isEmpty { opinionSection("남긴 댓글", rows: store.comments, headingGap: 16) } if !store.replies.isEmpty { opinionSection("남긴 대댓글", rows: store.replies, headingGap: 12) } + if store.canLoadMoreReplies { + loadMoreRepliesIndicator + } if !store.unspecified.isEmpty { opinionSection("남긴 의견", rows: store.unspecified, headingGap: 12) } @@ -96,6 +104,13 @@ private extension ClassMyOpinionsView { .scrollIndicators(.hidden) } + /// 목록 끝에 닿으면 다음 대댓글 페이지를 불러온다(Android `CommentsEndReach`). + var loadMoreRepliesIndicator: some View { + ProgressView() + .frame(maxWidth: .infinity) + .onAppear { send(.repliesEndReached) } + } + var emptyState: some View { Text("아직 남긴 의견이 없어요.") .pretendardFont(.regular13) diff --git a/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift b/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift index 107eb195..5e00330f 100644 --- a/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift +++ b/Projects/Feature/Class/Sources/ClassOwnerDashboard/Reducer/ClassOwnerDashboardFeature.swift @@ -59,6 +59,8 @@ public struct ClassOwnerDashboardFeature { public var feedbackError: ClassAIFeedbackError? public var insightRequestID: UUID? public var feedbackRequestID: UUID? + public var receivedLikeCount: Int? + public var myActivityRequestID: UUID? public var isGeneratingInsight = false public var isGeneratingFeedback = false public var insightUnknownRetryCount = 0 @@ -183,6 +185,10 @@ public struct ClassOwnerDashboardFeature { return .unavailable } + public var personalReceivedLikeCountText: String { + receivedLikeCount.map(String.init) ?? "—" + } + public var myOpinionCount: Int { if classResult != nil { return generatedFeedback?.perspectiveCount ?? 0 } return myOpinions.count @@ -193,7 +199,9 @@ public struct ClassOwnerDashboardFeature { } public var myOpinionsTitle: String { - "내가 남긴 의견 \(myOpinionCount)개 보기" + // 리포트 화면과 같이 개인 피드백을 받지 못하면 개수를 모르므로 표시하지 않는다. + if classResult != nil, generatedFeedback == nil { return "내가 남긴 의견 보기" } + return "내가 남긴 의견 \(myOpinionCount)개 보기" } public var canOpenMyOpinions: Bool { @@ -337,6 +345,7 @@ public struct ClassOwnerDashboardFeature { case fetchDashboard case generateInsight case generatePersonal + case fetchMyActivity } @CasePathable @@ -357,12 +366,14 @@ public struct ClassOwnerDashboardFeature { Result, wasUnknown: Bool ) + case myActivityResponse(instanceID: UUID, requestID: UUID, roomID: Int, Result) } public enum CancelID: Hashable, Sendable { case load(UUID) case generate(UUID) case generatePersonal(UUID) + case myActivity(UUID) } @CasePathable @@ -423,12 +434,14 @@ extension ClassOwnerDashboardFeature { state.requestID = nil state.insightRequestID = nil state.feedbackRequestID = nil + state.myActivityRequestID = nil state.isGeneratingInsight = false state.isGeneratingFeedback = false return .merge( .cancel(id: CancelID.load(state.instanceID)), .cancel(id: CancelID.generate(state.instanceID)), .cancel(id: CancelID.generatePersonal(state.instanceID)), + .cancel(id: CancelID.myActivity(state.instanceID)), .send(.delegate(.dismiss)) ) case .managementTapped: @@ -491,6 +504,8 @@ extension ClassOwnerDashboardFeature { state.isGeneratingFeedback = false state.insightUnknownRetryCount = 0 state.feedbackUnknownRetryCount = 0 + state.receivedLikeCount = nil + state.myActivityRequestID = nil let requestID = uuid() state.requestID = requestID state.sourceViewState = .loading @@ -514,8 +529,25 @@ extension ClassOwnerDashboardFeature { return .concatenate( .cancel(id: CancelID.generate(state.instanceID)), .cancel(id: CancelID.generatePersonal(state.instanceID)), + .cancel(id: CancelID.myActivity(state.instanceID)), fetch ) + case .fetchMyActivity: + let requestID = uuid() + state.myActivityRequestID = requestID + return .run { [classUseCase, instanceID = state.instanceID, roomID = state.room.id] send in + let result = await Result { + try await classUseCase.fetchMyActivity(roomID: roomID, cursor: nil) + } + .mapError(ClassError.from) + await send(.inner(.myActivityResponse( + instanceID: instanceID, + requestID: requestID, + roomID: roomID, + result + ))) + } + .cancellable(id: CancelID.myActivity(state.instanceID), cancelInFlight: true) case .generateInsight: let retryServerGeneration = state.insightError == .serverRetryAvailable let requestID = uuid() @@ -594,12 +626,16 @@ extension ClassOwnerDashboardFeature { case (true, true): return .merge( .send(.async(.generateInsight)), - .send(.async(.generatePersonal)) + .send(.async(.generatePersonal)), + .send(.async(.fetchMyActivity)) ) case (true, false): return .send(.async(.generateInsight)) case (false, true): - return .send(.async(.generatePersonal)) + return .merge( + .send(.async(.generatePersonal)), + .send(.async(.fetchMyActivity)) + ) case (false, false): return .none } @@ -609,6 +645,13 @@ extension ClassOwnerDashboardFeature { state.sourceViewState = .failed } return .none + case let .myActivityResponse(instanceID, requestID, roomID, result): + guard state.instanceID == instanceID, + state.myActivityRequestID == requestID, + state.room.id == roomID else { return .none } + state.myActivityRequestID = nil + state.receivedLikeCount = try? result.get().receivedLikeCount + return .none case let .dashboardResponse(instanceID, requestID, roomID, result): guard state.instanceID == instanceID, state.requestID == requestID, diff --git a/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift b/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift index 7abd2546..9b0830fc 100644 --- a/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift +++ b/Projects/Feature/Class/Sources/ClassOwnerDashboard/View/ClassOwnerDashboardView.swift @@ -553,9 +553,11 @@ private extension ClassOwnerDashboardView { reportChoice("사후 선택", value: feedback.postOption?.title ?? "선택 없음") } HStack(spacing: 0) { - reportActivity("\(feedback.perspectiveCount)", label: "의견") + reportActivity("\(feedback.perspectiveCount)", label: "댓글") Rectangle().fill(.beige600).frame(width: 1, height: 36) - reportActivity("\(feedback.commentCount)", label: "댓글") + reportActivity("\(feedback.commentCount)", label: "대댓글") + Rectangle().fill(.beige600).frame(width: 1, height: 36) + reportActivity(store.personalReceivedLikeCountText, label: "받은 추천") } } .padding(16) diff --git a/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift index 7c757696..d0886092 100644 --- a/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift +++ b/Projects/Feature/Class/Sources/ClassRecommend/Reducer/ClassRecommendFeature.swift @@ -377,10 +377,10 @@ extension ClassRecommendFeature { current = try await useCase.fetchTopicDraft(id: draft.id) } guard current.suggestStatus != "GENERATING" else { - throw ClassError.unknown("AI 주제 생성 시간이 초과됐어요. 다시 확인해 주세요.") + throw ClassError.userMessage("AI 주제 생성 시간이 초과됐어요. 다시 확인해 주세요.") } guard current.suggestStatus == "SUCCEEDED" else { - throw ClassError.unknown(current.clarificationMessage ?? "AI 주제를 만들지 못했어요. 조건을 확인해 주세요.") + throw ClassError.userMessage(current.clarificationMessage ?? "AI 주제를 만들지 못했어요. 조건을 확인해 주세요.") } return current } diff --git a/Projects/Feature/Class/Sources/ClassReport/Model/ClassReportHighlightRow.swift b/Projects/Feature/Class/Sources/ClassReport/Model/ClassReportHighlightRow.swift index 33188428..0182c49e 100644 --- a/Projects/Feature/Class/Sources/ClassReport/Model/ClassReportHighlightRow.swift +++ b/Projects/Feature/Class/Sources/ClassReport/Model/ClassReportHighlightRow.swift @@ -4,6 +4,8 @@ public struct ClassReportHighlightRow: Equatable, Identifiable { public let text: String public let isLast: Bool + public var quotedText: String { "“\(text)”" } + public init( id: Int, title: String, diff --git a/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift b/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift index ba243350..a6ed1dbf 100644 --- a/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift +++ b/Projects/Feature/Class/Sources/ClassReport/Reducer/ClassReportFeature.swift @@ -33,6 +33,8 @@ public struct ClassReportFeature { public var insight: ClassAIInsight? public var insightError: ClassAIFeedbackError? public var insightRequestID: UUID? + public var receivedLikeCount: Int? + public var myActivityRequestID: UUID? public var isGeneratingFeedback = false public var unknownRetryCount = 0 @@ -90,13 +92,17 @@ public struct ClassReportFeature { } public var serverParticipationRate: String { - guard let rate = classResult?.stats.participationRate else { return "" } - return rate.formatted(.number.precision(.fractionLength(0...1))) + "%" + guard let stats = classResult?.stats else { return "" } + guard stats.activeMemberCount > 0 else { return "아직 참여 없음" } + return (Double(stats.completedCount) / Double(stats.activeMemberCount) * 100) + .formatted(.number.precision(.fractionLength(0...1))) + "%" } public var serverCommentRate: String { - guard let rate = classResult?.stats.commentRate else { return "" } - return rate.formatted(.number.precision(.fractionLength(0...1))) + "%" + guard let stats = classResult?.stats else { return "" } + guard stats.activeMemberCount > 0 else { return "아직 참여 없음" } + return (Double(stats.commentWriterCount) / Double(stats.activeMemberCount) * 100) + .formatted(.number.precision(.fractionLength(0...1))) + "%" } public var serverParticipationCount: String { @@ -110,11 +116,13 @@ public struct ClassReportFeature { } public var serverParticipationProgress: Double { - min(max((classResult?.stats.participationRate ?? 0) / 100, 0), 1) + guard let stats = classResult?.stats, stats.activeMemberCount > 0 else { return 0 } + return min(max(Double(stats.completedCount) / Double(stats.activeMemberCount), 0), 1) } public var serverCommentProgress: Double { - min(max((classResult?.stats.commentRate ?? 0) / 100, 0), 1) + guard let stats = classResult?.stats, stats.activeMemberCount > 0 else { return 0 } + return min(max(Double(stats.commentWriterCount) / Double(stats.activeMemberCount), 0), 1) } public var serverOptionCounts: [Int: String] { @@ -124,12 +132,18 @@ public struct ClassReportFeature { } } + public var serverDistributionTotalCount: Int { + classResult?.stats.options.reduce(0) { $0 + max($1.postVoteCount, 0) } ?? 0 + } + public var serverDistributionTotalText: String { - "\(classResult?.stats.postVoteCount ?? 0)명" + "\(serverDistributionTotalCount)명" } public var serverDistributionProgress: Double { - min(max((classResult?.stats.options.first?.postVoteRatio ?? 0) / 100, 0), 1) + guard serverDistributionTotalCount > 0 else { return 0 } + return Double(max(classResult?.stats.options.first?.postVoteCount ?? 0, 0)) + / Double(serverDistributionTotalCount) } public var serverDistributionOptionCountTexts: [Int: String] { @@ -141,8 +155,19 @@ public struct ClassReportFeature { public var serverDistributionOptionRatioTexts: [Int: String] { guard let options = classResult?.stats.options else { return [:] } - return options.reduce(into: [:]) { values, option in - values[option.id] = option.postVoteRatio.formatted(.number.precision(.fractionLength(0...1))) + "%" + guard serverDistributionTotalCount > 0 else { + return options.reduce(into: [:]) { $0[$1.id] = "아직 참여 없음" } + } + var remainingTenths = 1000 + return options.enumerated().reduce(into: [:]) { values, pair in + let (index, option) = pair + let tenths = index == options.count - 1 + ? remainingTenths + : Int((Double(max(option.postVoteCount, 0)) * 1000 + / Double(serverDistributionTotalCount)).rounded()) + remainingTenths -= tenths + values[option.id] = (Double(tenths) / 10) + .formatted(.number.precision(.fractionLength(0...1))) + "%" } } @@ -218,6 +243,11 @@ public struct ClassReportFeature { feedback.map { String($0.commentCount) } ?? "—" } + /// Android와 같이 받은 추천 수를 모르면 0 대신 "—"로 표시한다. + public var personalReceivedLikeCountText: String { + receivedLikeCount.map(String.init) ?? "—" + } + public var myOpinionsButtonTitle: String { if classResult != nil, feedback == nil { return "내가 남긴 의견 보기" } return "내가 남긴 의견 \(myOpinionCount)개 보기" @@ -262,6 +292,7 @@ public struct ClassReportFeature { case fetchVoteHistory case generateFeedback case fetchInsight + case fetchMyActivity } @CasePathable @@ -277,6 +308,7 @@ public struct ClassReportFeature { wasUnknown: Bool ) case insightResponse(instanceID: UUID, requestID: UUID, roomID: Int, Result) + case myActivityResponse(instanceID: UUID, requestID: UUID, roomID: Int, Result) } @CasePathable @@ -290,6 +322,7 @@ public struct ClassReportFeature { case voteHistory(UUID) case generate(UUID) case insight(UUID) + case myActivity(UUID) } @Dependency(\.classResultUseCase) private var classUseCase @@ -337,12 +370,14 @@ extension ClassReportFeature { state.voteHistoryRequestID = nil state.feedbackRequestID = nil state.insightRequestID = nil + state.myActivityRequestID = nil state.isGeneratingFeedback = false return .merge( .cancel(id: CancelID.load(state.instanceID)), .cancel(id: CancelID.voteHistory(state.instanceID)), .cancel(id: CancelID.generate(state.instanceID)), .cancel(id: CancelID.insight(state.instanceID)), + .cancel(id: CancelID.myActivity(state.instanceID)), .send(.delegate(.dismiss)) ) case let .tabSelected(tab): @@ -375,6 +410,8 @@ extension ClassReportFeature { state.feedbackError = nil state.isGeneratingFeedback = false state.unknownRetryCount = 0 + state.receivedLikeCount = nil + state.myActivityRequestID = nil let requestID = uuid() state.requestID = requestID state.sourceViewState = .loading @@ -399,8 +436,25 @@ extension ClassReportFeature { .cancel(id: CancelID.voteHistory(state.instanceID)), .cancel(id: CancelID.generate(state.instanceID)), .cancel(id: CancelID.insight(state.instanceID)), + .cancel(id: CancelID.myActivity(state.instanceID)), fetch ) + case .fetchMyActivity: + let requestID = uuid() + state.myActivityRequestID = requestID + return .run { [classUseCase, instanceID = state.instanceID, roomID = state.room.id] send in + let result = await Result { + try await classUseCase.fetchMyActivity(roomID: roomID, cursor: nil) + } + .mapError(ClassError.from) + await send(.inner(.myActivityResponse( + instanceID: instanceID, + requestID: requestID, + roomID: roomID, + result + ))) + } + .cancellable(id: CancelID.myActivity(state.instanceID), cancelInFlight: true) case .fetchVoteHistory: let requestID = uuid() state.voteHistoryRequestID = requestID @@ -490,6 +544,7 @@ extension ClassReportFeature { } if classResult.myReportState != nil { effects.append(.send(.async(.generateFeedback))) + effects.append(.send(.async(.fetchMyActivity))) } if state.room.role != .member, (classResult.insightState == "SUCCEEDED" || classResult.insightState == "LIMITED") { @@ -503,6 +558,14 @@ extension ClassReportFeature { return .none } + case let .myActivityResponse(instanceID, requestID, roomID, result): + guard state.instanceID == instanceID, + state.myActivityRequestID == requestID, + state.room.id == roomID else { return .none } + state.myActivityRequestID = nil + state.receivedLikeCount = try? result.get().receivedLikeCount + return .none + case let .voteHistoryResponse(instanceID, requestID, roomID, result): guard state.instanceID == instanceID, state.voteHistoryRequestID == requestID, diff --git a/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift b/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift index 018b02cc..995de24f 100644 --- a/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift +++ b/Projects/Feature/Class/Sources/ClassReport/View/ClassReportView.swift @@ -130,9 +130,11 @@ private extension ClassReportView { .padding(.vertical, 12) .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) HStack(spacing: 0) { - activityCount(store.personalPerspectiveCountText, label: "작성한 의견") + activityCount(store.personalPerspectiveCountText, label: "댓글") Rectangle().fill(.beige600).frame(width: 1, height: 38) - activityCount(store.personalCommentCountText, label: "작성한 댓글") + activityCount(store.personalCommentCountText, label: "대댓글") + Rectangle().fill(.beige600).frame(width: 1, height: 38) + activityCount(store.personalReceivedLikeCountText, label: "받은 추천") } } else if store.voteHistoryRequestID != nil { SkeletonView(.round(cornerRadius: 2)).frame(height: 48) @@ -149,7 +151,7 @@ private extension ClassReportView { func serverResultPages(_ result: ClassResult) -> some View { let highlightRows = store.highlightRows ScrollView { - VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 24) { if let insight = store.insight { insightCard(insight) } else if result.insightState != nil { @@ -157,18 +159,20 @@ private extension ClassReportView { } VStack(alignment: .leading, spacing: 12) { - sectionHeading("클래스 참여 현황") - reportCard { + sectionHeading("클래스 참여 현황", textStyle: .headingSmall) + reportCard(padding: 17, horizontalPadding: 17, spacing: 16) { participationBar( title: "배틀 참여율", rate: store.serverParticipationRate, + caption: "참여한 멤버", detail: store.serverParticipationCount, progress: store.serverParticipationProgress, color: .primary500 ) participationBar( - title: "댓글 작성률", + title: "댓글 작성율", rate: store.serverCommentRate, + caption: "댓글을 남긴 멤버", detail: store.serverCommentCount, progress: store.serverCommentProgress, color: .secondary500 @@ -177,15 +181,15 @@ private extension ClassReportView { } VStack(alignment: .leading, spacing: 12) { - sectionHeading("입장 분포") - reportCard { + sectionHeading("입장 분포", textStyle: .headingSmall) + reportCard(padding: 17, horizontalPadding: 17) { stanceDistribution(result.stats.options) } } if !highlightRows.isEmpty { VStack(alignment: .leading, spacing: 12) { - sectionHeading("주요 의견") + sectionHeading("주요 의견", textStyle: .headingSmall) reportCard(padding: 0, horizontalPadding: 0, spacing: 0) { ForEach(highlightRows) { row in highlightRow(row) @@ -199,7 +203,7 @@ private extension ClassReportView { if result.myReportState != nil { VStack(alignment: .leading, spacing: 12) { - sectionHeading("내 AI 피드백") + sectionHeading("내 AI 피드백", textStyle: .headingSmall) reportCard { if case let .ready(feedback) = store.feedbackViewState { Text(feedback.summaryTitle) @@ -234,23 +238,23 @@ private extension ClassReportView { @ViewBuilder func insightCard(_ insight: ClassAIInsight) -> some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { ClassAIInsightHeading( title: "AI 인사이트", - titleFont: .semiBold12, - titleLineHeight: 17, + titleFont: .bold11, + titleLineHeight: 15, iconSize: 20, spacing: 6 ) Text(insight.text) - .pretendardFont(.regular13) + .pretendardFont(.bodySmall, lineHeightMultiple: 1.4) .foregroundStyle(.primary500) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(.secondary50) - .overlay(Rectangle().stroke(.primary100, lineWidth: 1)) + .padding(13) + .background(.secondary50, in: RoundedRectangle(cornerRadius: 2)) + .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(.primary100, lineWidth: 1)) } @ViewBuilder @@ -283,70 +287,84 @@ private extension ClassReportView { func participationBar( title: String, rate: String, + caption: String, detail: String, progress: Double, color: Color ) -> some View { - VStack(alignment: .leading, spacing: 5) { + VStack(alignment: .leading, spacing: 0) { HStack { Text(title) .pretendardFont(.semiBold12) Spacer() Text(rate) - .pretendardFont(.semiBold12) + .pretendardFont(.headingSmall) } + .frame(height: 20) .foregroundStyle(.gray800) - Text(detail) - .pretendardFont(.medium10) - .foregroundStyle(.gray300) + HStack { + Text(caption) + Spacer() + Text(detail) + } + .pretendardFont(.regular11) + .foregroundStyle(.gray300) + .frame(height: 15) + .padding(.top, 6) GeometryReader { geometry in ZStack(alignment: .leading) { Capsule().fill(.beige500) Capsule().fill(color).frame(width: geometry.size.width * progress) } } - .frame(height: 5) + .frame(height: 6) + .padding(.top, 4) } - .padding(.vertical, 2) } @ViewBuilder func stanceDistribution(_ options: [ClassResultOption]) -> some View { HStack(spacing: 24) { ZStack { - Circle().stroke(.secondary500, lineWidth: 12) Circle() + .inset(by: 6) + .stroke( + store.serverDistributionTotalCount == 0 ? Color.beige500 : Color.secondary500, + lineWidth: 12 + ) + Circle() + .inset(by: 6) .trim(from: 0, to: store.serverDistributionProgress) .stroke(.primary500, style: StrokeStyle(lineWidth: 12, lineCap: .butt)) .rotationEffect(.degrees(-90)) - VStack(spacing: 1) { + VStack(spacing: 0) { Text(store.serverDistributionTotalText) - .pretendardFont(.semiBold15) + .pretendardFont(.labelLarge, lineHeightMultiple: 1.5) .foregroundStyle(.gray800) Text("총 참여") - .pretendardFont(.medium10) + .pretendardFont(.medium10, lineHeightMultiple: 1.4) .foregroundStyle(.gray300) } } .frame(width: 100, height: 100) - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 12) { ForEach(Array(options.prefix(2).enumerated()), id: \.element.id) { index, option in - HStack(spacing: 6) { + HStack(spacing: 8) { Circle() .fill(index == 0 ? Color.primary500 : Color.secondary500) - .frame(width: 7, height: 7) - VStack(alignment: .leading, spacing: 2) { + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 0) { Text(option.title) - .pretendardFont(.semiBold11) + .pretendardFont(.semiBold12, lineHeightMultiple: 1.4) .foregroundStyle(.gray800) .lineLimit(1) Text(store.serverDistributionOptionCountTexts[option.id] ?? "") - .pretendardFont(.medium10) + .pretendardFont(.medium11, lineHeightMultiple: 1.4) .foregroundStyle(.gray300) } Spacer(minLength: 2) Text(store.serverDistributionOptionRatioTexts[option.id] ?? "") - .pretendardFont(.semiBold11) + .pretendardFont(family: .Bold, size: 12) .foregroundStyle(.gray800) } } @@ -357,17 +375,22 @@ private extension ClassReportView { @ViewBuilder func highlightRow(_ row: ClassReportHighlightRow) -> some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Text(row.title) - .pretendardFont(.semiBold11) - .foregroundStyle(.primary500) - Text(row.text) - .pretendardFont(.regular13) - .foregroundStyle(.gray700) + .pretendardFont(.semiBold12) + .foregroundStyle(.badgeFilledText) + .frame(height: 16) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.badgeFilledBackground, in: RoundedRectangle(cornerRadius: 2)) + Text(row.quotedText) + .pretendardFont(.regular13, lineHeightMultiple: 1.4) + .foregroundStyle(.gray400) .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 2) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) + .padding(16) } @ViewBuilder @@ -747,11 +770,14 @@ private extension ClassReportView { } } - func sectionHeading(_ title: String) -> some View { + func sectionHeading( + _ title: String, + textStyle: CustomSizeFont = .semiBold13 + ) -> some View { Text(title) - .pretendardFont(.semiBold13) + .pretendardFont(textStyle) .foregroundStyle(.gray800) - .frame(minHeight: 18) + .frame(minHeight: (textStyle.size * 1.4).rounded()) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift b/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift index a11a6f2d..6447e056 100644 --- a/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift +++ b/Projects/Feature/Class/Sources/ClassSetting/Reducer/ClassSettingFeature.swift @@ -7,6 +7,7 @@ import ClassDomainInterface import ComposableArchitecture +import CryptoKit import Foundation import PickeCoreUtility import PickeSharedUI @@ -95,7 +96,8 @@ public struct ClassSettingFeature { battleId: battle.id, allowsAnonymousOpinion: false, requiresComment: requiresComment, - ownerParticipates: ownerParticipates + ownerParticipates: ownerParticipates, + requestKey: creationRequestKey(subject: "battle:\(battle.id)") ) } guard let topicVersionID, aiQuestion != nil else { return nil } @@ -105,7 +107,8 @@ public struct ClassSettingFeature { topicVersionId: topicVersionID, allowsAnonymousOpinion: false, requiresComment: requiresComment, - ownerParticipates: ownerParticipates + ownerParticipates: ownerParticipates, + requestKey: creationRequestKey(subject: "topic:\(topicVersionID)") ) } @@ -138,10 +141,24 @@ public struct ClassSettingFeature { topicVersionId: topicVersionID, allowsAnonymousOpinion: false, requiresComment: requiresComment, - ownerParticipates: ownerParticipates + ownerParticipates: ownerParticipates, + requestKey: creationRequestKey(subject: "topic:\(topicVersionID)") ) } + private func creationRequestKey(subject: String) -> String { + let fields = [ + instanceID.uuidString, + Data(trimmedName.utf8).base64EncodedString(), + subject, + String((isDeadlineEnabled ? deadline : Date.distantFuture).timeIntervalSinceReferenceDate.bitPattern), + String(requiresComment), + String(ownerParticipates), + ] + let digest = SHA256.hash(data: Data(fields.joined(separator: "|").utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketRegisterNotice.swift b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketRegisterNotice.swift new file mode 100644 index 00000000..3dfb3128 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketRegisterNotice.swift @@ -0,0 +1,40 @@ +// +// ClassTicketRegisterNotice.swift +// Class +// +// Created by Picke on 11/4/26. +// + +import ClassDomainInterface +import PickeSharedUI + +/// 이용권 등록 화면 안내(Android `TicketRegisterDialog`). +public enum ClassTicketRegisterNotice: Equatable, Sendable { + /// 다른 계정에 등록된 코드. 뒤로가기로 화면을 닫는다. + case alreadyRegistered + case invalidCode + case registrationExpired + case alreadyActive + case registerFailed + + /// Android `toRegisterDialog`와 같은 매핑. 사용 중 이용권(`TICKET_409_ACTIVE`)만 iOS에서 따로 안내한다. + public init(error: ClassTicketError) { + switch error { + case .claimed: self = .alreadyRegistered + case .invalidCode, .revoked: self = .invalidCode + case .registrationExpired: self = .registrationExpired + case .activeTicket: self = .alreadyActive + case .notFound, .invalidResponse, .network: self = .registerFailed + } + } + + public var alert: CustomAlertState { + switch self { + case .alreadyRegistered: return .ticketAlreadyRegistered() + case .invalidCode: return .ticketInvalidCode() + case .registrationExpired: return .ticketRegistrationExpired() + case .alreadyActive: return .ticketAlreadyActive() + case .registerFailed: return .ticketRegisterFailed() + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift index 86650ddf..a3b71e73 100644 --- a/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift +++ b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift @@ -7,6 +7,7 @@ import ClassDomainInterface import ComposableArchitecture import Foundation import PickeCoreUtility +import PickeSharedUI @Reducer public struct ClassTicketFeature { @@ -20,19 +21,31 @@ public struct ClassTicketFeature { public var code = "" public var isChecking = false public var isLoadingExistingTicket = false - public var errorMessage: String? public var expandedFAQ: Int? public var preview: ClassTicketPreview? public var ticket: ClassTicket? public var registrationStartDate: Date? public var expirationDate: Date? + @Presents public var customAlert: CustomAlertState? + public var registerNotice: ClassTicketRegisterNotice? + public var toastMessage: String? + public var closesAfterToast = false public init(instanceID: UUID = UUID()) { self.instanceID = instanceID } + /// Android와 같이 대문자로 바꾸고 영문·숫자·`-`만 남긴다(서버도 공백·하이픈·대소문자를 무시). + public var normalizedCode: String { + // 글자 묶음이 아니라 유니코드 단위로 걸러 ASCII만 남긴다(예: "1️⃣", "A̲"의 결합 문자 제거). + let scalars = code.uppercased().unicodeScalars.filter { scalar in + ("A"..."Z").contains(scalar) || ("0"..."9").contains(scalar) || scalar == "-" + } + return String(scalars) + } + public var canCheckCode: Bool { - !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isChecking && !isLoadingExistingTicket + !normalizedCode.isEmpty && !isChecking && !isLoadingExistingTicket } public var navigationTitle: String { @@ -47,8 +60,9 @@ public struct ClassTicketFeature { capacity.map { "클래스 \($0)인 이용권" } ?? "클래스 이용권" } + /// 개요는 사용 중(ACTIVE) 이용권만 보여 준다(만료면 코드 입력). public var overviewTitle: String { - ticket?.status == .expired ? "만료된 이용권" : "사용 중인 이용권" + "사용 중인 이용권" } public var capacityDetail: String { @@ -57,7 +71,7 @@ public struct ClassTicketFeature { public var ticketPeriodText: String { if phase == .preview, let preview { - return "등록 후 \(preview.usageMonths)개월" + return "등록일부터 \(preview.usageMonths)개월" } return periodText } @@ -84,9 +98,13 @@ public struct ClassTicketFeature { public enum Action: ViewAction, BindableAction { case binding(BindingAction) case view(View) + case customAlert(PresentationAction) case checkResponse(Result) case registrationResponse(Result) - case myTicketResponse(Result) + /// 화면 진입 시 내 이용권 조회 결과. + case existingTicketResponse(Result) + /// 내가 등록한 코드를 다시 입력했을 때 내 이용권 조회 결과. + case registeredTicketResponse(Result) case delegate(DelegateAction) } @@ -98,6 +116,7 @@ public struct ClassTicketFeature { case confirmTapped case addRegistrationTapped case faqTapped(Int) + case toastShown } @CasePathable @@ -106,6 +125,8 @@ public struct ClassTicketFeature { case registrationConfirmed(startDate: Date, endDate: Date) case addRegistrationRequested case registered(Date, Date) + /// 내가 등록한 사용 중 이용권을 다시 입력함. 코디네이터가 기존 개요로 돌아가거나 이 화면을 개요로 바꾼다. + case existingTicketFound(ClassTicket) } public enum CancelID: Hashable, Sendable { @@ -120,8 +141,12 @@ public struct ClassTicketFeature { Reduce { state, action in switch action { case .binding(\.code): - state.errorMessage = nil + let wasLookingUpRegisteredTicket = state.preview != nil && state.isLoadingExistingTicket state.preview = nil + if wasLookingUpRegisteredTicket { + state.isLoadingExistingTicket = false + return .cancel(id: CancelID.existingTicket(state.instanceID)) + } guard state.isChecking else { return .none } state.isChecking = false return .cancel(id: CancelID.check(state.instanceID)) @@ -138,24 +163,23 @@ public struct ClassTicketFeature { else { return .none } state.shouldCheckExistingTicket = false state.isLoadingExistingTicket = true - return .run { [ticketUseCase] send in - let result: Result = await Result { - try await ticketUseCase.fetchMyTicket() - } - .mapError(Self.mapError) - await send(.myTicketResponse(result)) - } - .cancellable(id: CancelID.existingTicket(state.instanceID), cancelInFlight: true) + return fetchMyTicket(state: state) { .existingTicketResponse($0) } case .view(.backTapped): + // Android와 같이 확인 단계에서는 코드 입력으로 돌아간다. 등록 요청 중에는 무시한다. + if state.phase == .preview { + guard !state.isChecking else { return .none } + state.phase = .entry + state.preview = nil + return .none + } return .send(.delegate(.dismiss)) case .view(.confirmTapped): if state.phase == .preview { guard state.canRegister else { return .none } state.isChecking = true - state.errorMessage = nil - let submittedCode = state.code.trimmingCharacters(in: .whitespacesAndNewlines) + let submittedCode = state.normalizedCode return .run { [ticketUseCase] send in let result: Result = await Result { try await ticketUseCase.registerTicket(code: submittedCode) @@ -186,8 +210,7 @@ public struct ClassTicketFeature { case .view(.checkTapped): guard state.phase == .entry, state.canCheckCode else { return .none } state.isChecking = true - state.errorMessage = nil - let submittedCode = state.code.trimmingCharacters(in: .whitespacesAndNewlines) + let submittedCode = state.normalizedCode return .run { [ticketUseCase] send in let result: Result = await Result { try await ticketUseCase.previewTicket(code: submittedCode) @@ -197,6 +220,25 @@ public struct ClassTicketFeature { } .cancellable(id: CancelID.check(state.instanceID), cancelInFlight: true) + case .view(.toastShown): + state.toastMessage = nil + // 토스트를 띄운 뒤에 닫아야 사라지는 화면에서도 안내가 보인다. + guard state.closesAfterToast else { return .none } + state.closesAfterToast = false + return .send(.delegate(.dismiss)) + + case .customAlert(.presented(.confirmTapped)): + let notice = state.registerNotice + clearRegisterNotice(state: &state) + return notice == .alreadyRegistered ? .send(.delegate(.dismiss)) : .none + + case .customAlert(.presented(.cancelTapped)), .customAlert(.dismiss): + clearRegisterNotice(state: &state) + return .none + + case .customAlert: + return .none + case let .checkResponse(result): guard state.phase == .entry, state.isChecking else { return .none } state.isChecking = false @@ -204,19 +246,13 @@ public struct ClassTicketFeature { case let .success(preview): state.preview = preview if preview.registeredByMe { + // Android와 같이 내 이용권 상태를 확인해 사용 중이면 이용권 화면, 아니면 만료 안내. state.isLoadingExistingTicket = true - return .run { [ticketUseCase] send in - let result: Result = await Result { - try await ticketUseCase.fetchMyTicket() - } - .mapError(Self.mapError) - await send(.myTicketResponse(result)) - } - .cancellable(id: CancelID.existingTicket(state.instanceID), cancelInFlight: true) + return fetchMyTicket(state: state) { .registeredTicketResponse($0) } } state.phase = .preview case let .failure(error): - state.errorMessage = error.message + presentRegisterNotice(.init(error: error), state: &state) } return .none @@ -231,23 +267,39 @@ public struct ClassTicketFeature { state.phase = .registered return .send(.delegate(.registered(ticket.activatedAt, ticket.expiresAt))) case let .failure(error): - state.errorMessage = error.message + presentRegisterNotice(.init(error: error), state: &state) } return .none - case let .myTicketResponse(result): + case let .existingTicketResponse(result): guard state.phase == .entry, state.isLoadingExistingTicket else { return .none } state.isLoadingExistingTicket = false switch result { - case let .success(ticket): + case let .success(ticket) where ticket.status == .active: state.ticket = ticket state.registrationStartDate = ticket.activatedAt state.expirationDate = ticket.expiresAt state.phase = .overview + case .success, .failure(.notFound): + // 만료됐거나 없으면 Android와 같이 코드 입력부터 시작한다. + break + case .failure: + state.toastMessage = "정보를 불러오지 못했어요" + state.closesAfterToast = true + } + return .none + + case let .registeredTicketResponse(result): + // 코드를 고치면 preview가 비므로 늦게 온 결과는 무시된다. + guard state.phase == .entry, state.isLoadingExistingTicket, state.preview != nil else { return .none } + state.isLoadingExistingTicket = false + switch result { + case let .success(ticket) where ticket.status == .active: + return .send(.delegate(.existingTicketFound(ticket))) + case .success: + presentRegisterNotice(.registrationExpired, state: &state) case let .failure(error): - if error != .notFound { - state.errorMessage = error.message - } + presentRegisterNotice(.init(error: error), state: &state) } return .none @@ -255,6 +307,36 @@ public struct ClassTicketFeature { return .none } } + .ifLet(\.$customAlert, action: \.customAlert) { + CustomConfirmAlert() + } + } + + private func fetchMyTicket( + state: State, + response: @escaping @Sendable (Result) -> Action + ) -> Effect { + .run { [ticketUseCase] send in + let result: Result = await Result { + try await ticketUseCase.fetchMyTicket() + } + .mapError(Self.mapError) + await send(response(result)) + } + .cancellable(id: CancelID.existingTicket(state.instanceID), cancelInFlight: true) + } + + private func presentRegisterNotice( + _ notice: ClassTicketRegisterNotice, + state: inout State + ) { + state.registerNotice = notice + state.customAlert = notice.alert + } + + private func clearRegisterNotice(state: inout State) { + state.registerNotice = nil + state.customAlert = nil } private static func mapError(_ error: Error) -> ClassTicketError { diff --git a/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift index c609f058..1bb03fc2 100644 --- a/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift +++ b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift @@ -6,11 +6,13 @@ import ComposableArchitecture import PickeCoreUtility import PickeDesignKit +import PickeSharedUI import SwiftUI @ViewAction(for: ClassTicketFeature.self) public struct ClassTicketView: View { @Bindable public var store: StoreOf + @FocusState private var isCodeFocused: Bool public init(store: StoreOf) { self.store = store @@ -46,6 +48,16 @@ public struct ClassTicketView: View { .screenBackground(.surfaceBeigeDefault) .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } + .customAlert($store.scope(state: \.customAlert, action: \.customAlert)) + .onChange(of: store.customAlert != nil) { _, isPresented in + // 안내가 키보드에 가리지 않게 입력 포커스를 내린다. + if isPresented { isCodeFocused = false } + } + .onChange(of: store.toastMessage, initial: true) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } } } @@ -95,14 +107,8 @@ private extension ClassTicketView { .autocorrectionDisabled() .keyboardType(.asciiCapable) .pickeTextField(height: 44) + .focused($isCodeFocused) .accessibilityIdentifier("class-ticket-code") - - if let errorMessage = store.errorMessage { - Text(errorMessage) - .pretendardFont(.bodySmall) - .foregroundStyle(.errorDefault) - .accessibilityIdentifier("class-ticket-error") - } } } @@ -112,12 +118,12 @@ private extension ClassTicketView { faqRow( index: 0, question: "이용권 코드는 어디서 확인하나요?", - answer: "리워드로 받은 이메일에서 이용권 코드를 확인할 수 있어요." + answer: "펀딩 리워드 발송 안내에서 확인해 주세요. 코드를 받지 못했다면 해당 펀딩의 문의 경로를 이용해 주세요." ) faqRow( index: 1, question: "클래스 참여 코드와 다른가요?", - answer: "이용권 코드는 클래스를 개설할 때 사용해요. 참여 코드는 다른 사람의 클래스에 들어갈 때 사용해요." + answer: "이용권 코드는 모임을 여는 데 사용하는 리워드예요. 모임에 참가하려면 '클래스 탭' - '클래스 참여하기'에서 참여 코드를 입력해 주세요." ) } } @@ -161,28 +167,31 @@ private extension ClassTicketView { @ViewBuilder func previewContent() -> some View { VStack(alignment: .leading, spacing: 0) { - Text("이용권 내용을 확인해 주세요") + Text("이 이용권을 등록할까요?") .pretendardFont(.semiBold24) .foregroundStyle(.gray800) .padding(.bottom, 8) - Text("확인 후 등록하면 이용 기간이 바로 시작돼요") + Text("이용 조건과 주의사항을 확인해 주세요") .pretendardFont(.bodyMedium) .foregroundStyle(.gray300) .padding(.bottom, 36) ticketCard() - if let errorMessage = store.errorMessage { - Text(errorMessage) - .pretendardFont(.bodySmall) - .foregroundStyle(.errorDefault) - .padding(.top, 12) - .accessibilityIdentifier("class-ticket-error") - } + Text("등록하는 순간 이용 기간이 시작돼요\n클래스를 만들기 전에도 기간은 흘러가요") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) + .padding(.top, 12) - bottomSection() + Text("등록 후 마이페이지에서 이용권을 확인할 수 있어요") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity) .padding(.top, 40) + + bottomSection() + .padding(.top, 10) } .padding(.horizontal, 16) .padding(.top, 20) @@ -241,6 +250,9 @@ private extension ClassTicketView { @ViewBuilder func overviewBottomSection() -> some View { VStack(spacing: 10) { + Text("등록 후 마이페이지에서 이용권을 확인할 수 있어요") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) Button("이용권 추가등록하기") { send(.addRegistrationTapped) } diff --git a/Projects/Feature/Class/Tests/Sources/ClassAIVoteTests.swift b/Projects/Feature/Class/Tests/Sources/ClassAIVoteTests.swift index bc1a1011..34aa6eef 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassAIVoteTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassAIVoteTests.swift @@ -489,6 +489,14 @@ private struct ClassVoteUseCaseStub: ClassBattleInterface { return .init(voteID: 5, status: .preVote) } + func submitMidVote( + classID _: Int, + checkpointID _: Int, + optionID _: Int + ) async throws -> ClassVoteReceipt { + throw ClassBattleError.midVoteUnavailable + } + func completeTTS(classID _: Int) async throws { throw ClassBattleError.invalidResponse } diff --git a/Projects/Feature/Class/Tests/Sources/ClassBattleFlowTests.swift b/Projects/Feature/Class/Tests/Sources/ClassBattleFlowTests.swift index 26ba2cb5..a9205c62 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassBattleFlowTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassBattleFlowTests.swift @@ -13,6 +13,377 @@ import Testing struct ClassBattleFlowTests { private let requestID = UUID(uuidString: "00000000-0000-0000-0000-000000000042")! + @Test + func 중간투표_선택_전에는_청취완료를_제출할_수_없다() { + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false) + state.viewState = .loaded + state.hasListened = true + state.pendingCheckpointID = 801 + #expect(!state.canSubmit) + state.pendingCheckpointID = nil + #expect(state.canSubmit) + } + + @Test + func 중간투표_후에는_사전투표를_변경할_수_없다() async { + var state = battleState(room: .mocks[0]) + state.session = session(step: .preVote, commentRequired: false, midVoteEnabled: true) + state.viewState = .loaded + state.selectedOptionID = 31 + state.voteHistory = ClassVoteHistory( + battleTitle: "토론", + preVote: .init(optionID: 31, label: "A", title: "찬성"), + postVote: nil, + status: .preVote, + opinionChanged: nil + ) + state.selectedPaths = [801: 802] + #expect(state.isPreVoteLocked) + #expect(!state.canSubmit) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + await store.send(.view(.optionTapped(32))) + #expect(store.state.selectedOptionID == 31) + } + + @Test + func 사후투표_중간투표_누락_복구는_오디오_시간_관찰을_재개한다() async { + let spy = ClassBattleFlowSpy() + var state = battleState(room: .mocks[0]) + state.session = session(step: .postVote, commentRequired: false) + state.viewState = .loaded + state.needsMidVoteRecovery = true + state.isAudioReady = true + state.submitRequestID = requestID + state.currentTime = 15 + state.hasListened = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: spy) + } + await store.send(.inner(.submissionResponse(requestID, .postVote, .failure(.midVoteRequired)))) { + $0.submitRequestID = nil + $0.errorMessage = ClassBattleError.midVoteRequired.message + $0.hasListened = false + $0.currentTime = 0 + $0.pendingSeekTime = 0 + $0.isPlaying = true + } + await store.finish() + #expect(await spy.timeSubscriptions == 1) + } + + @Test + func 중간투표_선택_경로의_대본만_노출한다() { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, + nodeName: "선택", + audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 1, startTimeMs: 0, speakerType: .a, speakerName: "A", text: "선택하세요.")], + interactiveOptions: [ + ScenarioInteractiveOption(label: "첫째", nextNodeId: 802, optionID: 9001), + ScenarioInteractiveOption(label: "둘째", nextNodeId: 803, optionID: 9002) + ] + ), + ScenarioNode( + nodeId: 802, nodeName: "첫째 경로", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 2, startTimeMs: 2_000, speakerType: .a, speakerName: "A", text: "첫째.")], + interactiveOptions: [] + ), + ScenarioNode( + nodeId: 803, nodeName: "둘째 경로", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 3, startTimeMs: 2_000, speakerType: .b, speakerName: "B", text: "둘째.")], + interactiveOptions: [] + ) + ] + ) + #expect(ClassBattleTranscriptLine.from(scenario: scenario).count == 1) + #expect(ClassBattleTranscriptLine.from(scenario: scenario, selectedPaths: [801: 803]) + .map(\.text) == ["선택하세요.", "둘째."]) + } + + @Test + func 이미_기록된_중간투표는_재생할_때_다시_제출하지_않는다() async { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "선택", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 1, startTimeMs: 0, speakerType: .a, speakerName: "A", text: "선택")], + interactiveOptions: [ScenarioInteractiveOption(label: "첫째", nextNodeId: 802, optionID: 9001)] + ), + ScenarioNode( + nodeId: 802, nodeName: "첫째 경로", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 2, startTimeMs: 2_000, speakerType: .a, speakerName: "A", text: "첫째")], + interactiveOptions: [] + ) + ] + ) + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false) + state.viewState = .loaded + state.isAudioReady = true + state.isPlaying = true + state.audioDuration = 4 + state.scenario = scenario + state.currentNodeID = 801 + state.selectedPaths = [801: 802] + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + } + + await store.send(.inner(.playerTimeUpdated(requestID, 2))) { + $0.currentTime = 2 + $0.currentNodeID = 802 + $0.transcript = ClassBattleTranscriptLine.from(scenario: scenario, selectedPaths: [801: 802]) + $0.pendingSeekTime = 2 + } + #expect(store.state.pendingCheckpointID == nil) + await store.finish() + } + + @Test + func 다른_분기를_선택하면_해당_경로의_오디오를_불러온다() async throws { + let commonURL = try #require(URL(string: "https://example.com/common.mp3")) + let branchURL = try #require(URL(string: "https://example.com/path-b.mp3")) + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: ["COMMON": commonURL.absoluteString, "PATH_B": branchURL.absoluteString], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "COMMON", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 1, startTimeMs: 0, speakerType: .a, speakerName: "A", text: "선택")], + interactiveOptions: [ScenarioInteractiveOption(label: "둘째", nextNodeId: 803, optionID: 9002)] + ), + ScenarioNode( + nodeId: 803, nodeName: "NODE_B", audioDuration: 2, + autoNextNodeId: nil, + scripts: [ScenarioScript(scriptId: 2, startTimeMs: 2_000, speakerType: .b, speakerName: "B", text: "둘째")], + interactiveOptions: [] + ) + ] + ) + let spy = ClassBattleFlowSpy() + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false) + state.viewState = .loaded + state.scenario = scenario + state.currentNodeID = 801 + state.pendingCheckpointID = 801 + state.selectedMidOptionID = 803 + state.isAudioReady = true + state.loadedAudioURL = commonURL + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .constant(requestID) + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: spy) + } + + await store.send(.view(.confirmMidVoteTapped)) { + $0.selectedPaths = [801: 803] + $0.transcript = ClassBattleTranscriptLine.from(scenario: scenario, selectedPaths: [801: 803]) + $0.currentNodeID = 803 + $0.pendingCheckpointID = nil + $0.selectedMidOptionID = nil + $0.currentTime = 2 + $0.pendingSeekTime = 2 + $0.audioRequestID = requestID + $0.loadedAudioURL = branchURL + $0.isAudioReady = false + } + await store.receive(\.inner, .audioLoaded(requestID, false, 0)) { + $0.audioRequestID = nil + $0.errorMessage = "오디오를 재생할 수 없어요. 다시 시도해 주세요." + } + await store.finish() + #expect(await spy.loadedURLs == [branchURL]) + } + + @Test + func 서버에서_중간투표가_비활성화되면_로컬_선택으로_재생을_계속한다() async { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "COMMON", audioDuration: 2, + autoNextNodeId: nil, scripts: [], + interactiveOptions: [ScenarioInteractiveOption(label: "다음", nextNodeId: 802, optionID: 9001)] + ), + ScenarioNode( + nodeId: 802, nodeName: "NEXT", audioDuration: 2, + autoNextNodeId: nil, scripts: [], interactiveOptions: [] + ) + ] + ) + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false, midVoteEnabled: true) + state.scenario = scenario + state.currentNodeID = 801 + state.pendingCheckpointID = 801 + state.selectedMidOptionID = 802 + state.midVoteRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + } + await store.send(.inner(.midVoteResponse(requestID, 801, 9001, .failure(.midVoteUnavailable)))) { + $0.midVoteRequestID = nil + $0.selectedPaths = [801: 802] + $0.currentNodeID = 802 + $0.pendingCheckpointID = nil + $0.selectedMidOptionID = nil + $0.currentTime = 0 + $0.pendingSeekTime = 0 + $0.isPlaying = true + $0.midVoteDisabledLocally = true + $0.errorMessage = ClassBattleError.midVoteUnavailable.message + } + await store.finish() + } + + @Test + func 사후투표_복구_후에도_사용자가_고른_최종_선택을_제출한다() async { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "COMMON", audioDuration: 2, + autoNextNodeId: nil, scripts: [], + interactiveOptions: [ScenarioInteractiveOption(label: "다음", nextNodeId: 802, optionID: 9001)] + ), + ScenarioNode( + nodeId: 802, nodeName: "NEXT", audioDuration: 2, + autoNextNodeId: nil, scripts: [], interactiveOptions: [] + ) + ] + ) + let spy = ClassBattleFlowSpy() + var state = battleState(room: .mocks[0]) + state.session = session(step: .postVote, commentRequired: false, midVoteEnabled: true) + state.viewState = .loaded + state.scenario = scenario + state.voteHistory = ClassVoteHistory( + battleTitle: "토론", + preVote: .init(optionID: 31, label: "A", title: "찬성"), + postVote: nil, + status: .postVote, + opinionChanged: nil + ) + state.selectedOptionID = 32 + state.needsMidVoteRecovery = true + state.hasListened = true + state.pendingCheckpointID = 801 + state.selectedMidOptionID = 802 + state.midVoteRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .constant(requestID) + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: spy) + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: spy) + } + await store.send(.inner(.midVoteResponse(requestID, 801, 9001, .success(true)))) { + $0.midVoteRequestID = nil + $0.selectedPaths = [801: 802] + $0.currentNodeID = 802 + $0.pendingCheckpointID = nil + $0.selectedMidOptionID = nil + $0.currentTime = 0 + $0.pendingSeekTime = 0 + $0.isPlaying = true + } + #expect(store.state.selectedOptionID == 32) + await store.send(.view(.submitTapped)) { $0.submitRequestID = requestID } + await store.receive(\.inner, .submissionResponse(requestID, .postVote, .failure(.invalidResponse))) { + $0.submitRequestID = nil + $0.errorMessage = ClassBattleError.invalidResponse.message + } + await store.finish() + #expect(await spy.postVoteOptionIDs == [32]) + } + + @Test + func 중간투표_확인은_체크포인트와_서버_선택지_ID를_제출한다() async { + let scenario = BattleScenario( + battleId: 22, + title: "선택형 배틀", + philosophers: [], + isInteractive: true, + startNodeId: 801, + recommendedPathKey: .common, + audios: [:], + nodes: [ + ScenarioNode( + nodeId: 801, nodeName: "COMMON", audioDuration: 2, + autoNextNodeId: nil, scripts: [], + interactiveOptions: [ScenarioInteractiveOption(label: "다음", nextNodeId: 802, optionID: 9001)] + ), + ScenarioNode( + nodeId: 802, nodeName: "NEXT", audioDuration: 2, + autoNextNodeId: nil, scripts: [], interactiveOptions: [] + ) + ] + ) + let spy = ClassBattleFlowSpy() + var state = battleState(room: .mocks[0]) + state.session = session(step: .ttsListening, commentRequired: false, midVoteEnabled: true) + state.scenario = scenario + state.currentNodeID = 801 + state.pendingCheckpointID = 801 + state.selectedMidOptionID = 802 + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .constant(requestID) + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: spy) + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: spy) + } + await store.send(.view(.confirmMidVoteTapped)) { $0.midVoteRequestID = requestID } + await store.receive(\.inner, .midVoteResponse(requestID, 801, 9001, .success(true))) { + $0.midVoteRequestID = nil + $0.selectedPaths = [801: 802] + $0.currentNodeID = 802 + $0.pendingCheckpointID = nil + $0.selectedMidOptionID = nil + $0.currentTime = 0 + $0.pendingSeekTime = 0 + $0.isPlaying = true + } + await store.finish() + #expect(await spy.midVoteArguments.map { "\($0.0):\($0.1):\($0.2)" } == ["\(ClassRoom.mocks[0].id):801:9001"]) + } + @Test func TTS_대본은_다음_노드_시작을_넘기지_않는다() { let scenario = BattleScenario( @@ -172,6 +543,25 @@ struct ClassBattleFlowTests { #expect(state.perspectiveRows.map(\.commentCountLabel) == ["3", "2,300"]) } + @Test + func 의견_작성_시각은_N분_전_형식으로_표시한다() { + let item = ClassPerspectiveItem( + id: 1, + author: .init(userTag: "member", nickname: "참가자", characterType: nil, characterImageURL: nil), + option: .init(id: 1, label: "A", title: "찬성", stance: "찬성"), + content: "의견", + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: false, + status: nil, + createdAt: Date().addingTimeInterval(-150) + ) + let row = ClassPerspectiveRow(id: 1, perspective: item, likeCountLabel: "0", commentCountLabel: "0") + + #expect(row.createdAtLabel == "2분 전") + } + @Test func 이미_제출한_의견은_빈_재조회_응답으로_다시_작성할_수_없다() async { var state = battleState(room: .mocks[0]) @@ -288,11 +678,15 @@ struct ClassBattleFlowTests { ClassBattleTranscriptLine(id: 2, startTime: 2, speakerName: "소크라테스", speakerImageURL: "https://example.com/b.png", text: "생각해요.", side: "B"), ClassBattleTranscriptLine(id: 3, startTime: 4, speakerName: "소크라테스", speakerImageURL: "https://example.com/b.png", text: "반대해요.", side: "B") ] - await store.receive(\.inner, .transcriptLoaded(requestID, expected)) { + await store.receive(\.inner, .scenarioLoaded(requestID, scenario)) { + $0.scenario = scenario + $0.currentNodeID = scenario.startNodeId $0.transcript = expected } let audioURL = try #require(URL(string: "https://example.com/class-battle.mp3")) - await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) + await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) { + $0.loadedAudioURL = audioURL + } await store.receive(\.inner, .audioLoaded(requestID, false, 0)) { $0.audioRequestID = nil $0.errorMessage = "오디오를 재생할 수 없어요. 다시 시도해 주세요." @@ -335,7 +729,16 @@ struct ClassBattleFlowTests { $0.audioRequestID = requestID } let audioURL = try #require(URL(string: "https://example.com/class-battle.mp3")) - await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) + let scenario = try await ClassBattleFlowScenarioStub(spy: ClassBattleFlowSpy()) + .fetchScenario(battleId: session.battle.id) + await store.receive(\.inner, .scenarioLoaded(requestID, scenario)) { + $0.scenario = scenario + $0.currentNodeID = scenario.startNodeId + $0.transcript = ClassBattleTranscriptLine.from(scenario: scenario) + } + await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) { + $0.loadedAudioURL = audioURL + } await store.receive(\.inner, .audioLoaded(requestID, false, 0)) { $0.audioRequestID = nil $0.errorMessage = "오디오를 재생할 수 없어요. 다시 시도해 주세요." @@ -344,6 +747,7 @@ struct ClassBattleFlowTests { $0.audioRequestID = requestID $0.errorMessage = nil } + await store.receive(\.inner, .scenarioLoaded(requestID, scenario)) await store.receive(\.inner, .audioURLResponse(requestID, .success(audioURL))) await store.receive(\.inner, .audioLoaded(requestID, false, 0)) { $0.audioRequestID = nil @@ -804,7 +1208,7 @@ struct ClassBattleFlowTests { status: .completed, opinionChanged: false ) - #expect(state.opinionChangeLabel == "처음 생각을 유지했어요") + #expect(state.opinionChangeLabel == "생각이 동일해요") state.voteHistory = ClassVoteHistory( battleTitle: "토론", @@ -822,7 +1226,7 @@ struct ClassBattleFlowTests { status: .completed, opinionChanged: nil ) - #expect(state.opinionChangeLabel == "처음 생각을 유지했어요") + #expect(state.opinionChangeLabel == "생각이 동일해요") } @Test @@ -850,9 +1254,443 @@ struct ClassBattleFlowTests { await store.finish() } + @Test + func 의견_더보기_메뉴는_Android와_같이_작성자와_상태로_정한다() { + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: false)) == .report) + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: true)) == .editAndDelete) + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: true, status: "REJECTED")) == .editOnly) + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: true, status: "pending")) == .hidden) + #expect(ClassPerspectiveMenuKind(listItem: perspectiveItem(id: 1, isMine: nil)) == .hidden) + #expect(!ClassPerspectiveMenuKind.hidden.hasMenu) + #expect(ClassPerspectiveMenuKind(myStatus: nil) == .editAndDelete) + } + + @Test + func 내_의견_수정은_입력창에_내용을_채우고_보내면_수정_후_다시_불러온다() async { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + #expect(!state.shouldShowComposer) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + $0.perspectiveUseCase = MockPerspectiveRepository() + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [perspectiveItem(id: 5, isMine: true)], nextCursor: nil, hasNext: false) + ) + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveMoreTapped(5))) + #expect(store.state.menuTargetPerspectiveID == 5) + await store.send(.view(.perspectiveEditTapped(5))) + #expect(store.state.menuTargetPerspectiveID == nil) + #expect(store.state.editingPerspectiveID == 5) + #expect(store.state.draft == "의견 5") + #expect(store.state.shouldShowComposer) + #expect(store.state.canSubmit) + + await store.send(.view(.draftChanged("고친 의견"))) + await store.send(.view(.submitTapped)) + #expect(store.state.mutationRequestID != nil) + #expect(store.state.submitRequestID == nil) + await store.receive(\.inner) // updatePerspectiveResponse + + #expect(store.state.editingPerspectiveID == nil) + #expect(store.state.draft.isEmpty) + #expect(store.state.mutationRequestID == nil) + #expect(store.state.perspectiveRequestID != nil || store.state.perspectives.map(\.id) == [5]) + } + + @Test + func 의견_수정을_취소하면_수정_상태와_입력이_비워진다() async { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.view(.perspectiveEditTapped(5))) + await store.send(.view(.cancelEditTapped)) + + #expect(store.state.editingPerspectiveID == nil) + #expect(store.state.draft.isEmpty) + #expect(!store.state.shouldShowComposer) + } + + @Test + func 내_의견을_삭제하면_목록에서_빼고_다시_쓸_수_있다() async { + var state = completedState(perspectives: [ + perspectiveItem(id: 5, isMine: true), + perspectiveItem(id: 9, isMine: false), + ]) + state.hasPublishedPerspective = true + state.hasSubmittedInCurrentSession = true + state.myPerspective = perspectiveItem(id: 5, isMine: true) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + $0.perspectiveUseCase = MockPerspectiveRepository() + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: ClassBattleFlowSpy()) + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveDeleteTapped(9))) + #expect(store.state.customAlert == nil) + await store.send(.view(.perspectiveDeleteTapped(5))) + #expect(store.state.customAlert == .deletePerspective()) + await store.send(.scope(.customAlert(.presented(.confirmTapped)))) + await store.receive(\.inner) // deletePerspectiveResponse + + #expect(store.state.perspectives.map(\.id) == [9]) + #expect(store.state.myPerspective == nil) + #expect(store.state.hasPublishedPerspective == false) + #expect(!store.state.hasSubmittedInCurrentSession) + #expect(store.state.shouldShowComposer) + } + + @Test + func 남의_의견_신고는_확인_후_결과를_토스트로_알린다() async { + let state = completedState(perspectives: [perspectiveItem(id: 9, isMine: false)]) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.perspectiveUseCase = MockPerspectiveRepository() + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveReportTapped(9))) + #expect(store.state.customAlert == .report()) + await store.send(.scope(.customAlert(.presented(.confirmTapped)))) + await store.receive(\.inner) // reportPerspectiveResponse + #expect(store.state.toastMessage == "신고가 정상 접수되었습니다.") + await store.send(.view(.toastShown)) + #expect(store.state.toastMessage == nil) + + await store.send(.view(.perspectiveReportTapped(9))) + await store.send(.scope(.customAlert(.presented(.cancelTapped)))) + #expect(store.state.reportTargetPerspectiveID == nil) + #expect(store.state.mutationRequestID == nil) + + var duplicate = state + duplicate.mutationRequestID = requestID + let duplicateStore = TestStore(initialState: duplicate) { ClassBattleFlowFeature() } + duplicateStore.exhaustivity = .off + await duplicateStore.send(.inner(.reportPerspectiveResponse(requestID, .failure(.alreadyReported)))) + #expect(duplicateStore.state.toastMessage == "이미 신고한 사용자입니다.") + + var failed = state + failed.mutationRequestID = requestID + let failedStore = TestStore(initialState: failed) { ClassBattleFlowFeature() } + failedStore.exhaustivity = .off + await failedStore.send(.inner(.reportPerspectiveResponse(requestID, .failure(.unknown("x"))))) + #expect(failedStore.state.toastMessage == "신고하지 못했어요") + } + + @Test + func 검수_중인_내_의견은_목록_위_상태_카드로_보이고_메뉴가_없다() async { + var state = completedState(perspectives: [perspectiveItem(id: 9, isMine: false)]) + state.myPerspective = perspectiveItem(id: 5, isMine: true, status: "PENDING") + #expect(state.hiddenMyPerspectiveRow?.perspective.id == 5) + #expect(state.hiddenMyPerspectiveRow?.statusBadgeTitle == "검수중") + #expect(state.hiddenMyPerspectiveRow?.menuKind == .hidden) + + state.myPerspective = perspectiveItem(id: 5, isMine: nil, status: "REJECTED") + #expect(state.hiddenMyPerspectiveRow?.statusBadgeTitle == "거절됨") + #expect(state.hiddenMyPerspectiveRow?.menuKind == .editOnly) + + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + state.myPerspective = perspectiveItem(id: 5, isMine: true, status: "PENDING") + let pendingStore = TestStore(initialState: state) { ClassBattleFlowFeature() } + pendingStore.exhaustivity = .off + await pendingStore.send(.view(.perspectiveMoreTapped(5))) + #expect(pendingStore.state.menuTargetPerspectiveID == nil) + await store.send(.view(.perspectiveEditTapped(5))) + #expect(store.state.editingPerspectiveID == 5) + } + + @Test + func 상세_이동으로_화면이_가려져도_진행_중인_신고는_취소되지_않는다() async { + let gate = ReportGate() + let state = completedState(perspectives: [perspectiveItem(id: 9, isMine: false)]) + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + $0.perspectiveUseCase = SuspendedReportPerspective(gate: gate) + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveReportTapped(9))) + await store.send(.scope(.customAlert(.presented(.confirmTapped)))) + await gate.waitForStart() + await store.send(.view(.onDisappear)) + #expect(store.state.mutationRequestID != nil) + await gate.release() + await store.receive(\.inner) // reportPerspectiveResponse + #expect(store.state.mutationRequestID == nil) + #expect(store.state.toastMessage == "신고가 정상 접수되었습니다.") + } + + @Test + func 의견_변경_요청이_진행_중이면_다른_메뉴_동작을_막는다() async { + var state = completedState(perspectives: [ + perspectiveItem(id: 5, isMine: true), + perspectiveItem(id: 9, isMine: false), + ]) + state.mutationRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.view(.perspectiveMoreTapped(9))) + #expect(store.state.menuTargetPerspectiveID == nil) + await store.send(.view(.perspectiveReportTapped(9))) + await store.send(.view(.perspectiveDeleteTapped(5))) + #expect(store.state.customAlert == nil) + await store.send(.view(.perspectiveEditTapped(5))) + #expect(store.state.editingPerspectiveID == nil) + #expect(store.state.mutationRequestID == requestID) + } + + @Test + func 수정_중_같은_의견의_수정을_다시_눌러도_고치던_내용을_유지한다() async { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.view(.perspectiveEditTapped(5))) + await store.send(.view(.draftChanged("고치는 중"))) + await store.send(.view(.perspectiveEditTapped(5))) + #expect(store.state.draft == "고치는 중") + } + + @Test + func 의견을_제출한_뒤_완료_단계로_오면_내_의견을_다시_받아_검수_중_카드를_보여_준다() async throws { + var state = battleState(room: .mocks[0]) + state.loadRequestID = requestID + state.hasSubmittedInCurrentSession = true + let pending = perspectiveItem(id: 5, isMine: true, status: "PENDING") + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.audioPlayer = ClassBattleFlowAudioPlayer(spy: ClassBattleFlowSpy()) + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [], nextCursor: nil, hasNext: false), + myPerspectives: [pending] + ) + } + store.exhaustivity = .off + + await store.send(.inner(.progressResponse( + requestID, + .success(.init(session: session(step: .completed, commentRequired: false), voteHistory: nil)) + ))) + #expect(store.state.hasPublishedPerspective == true) + let myPerspectiveRequestID = try #require(store.state.myPerspectiveRequestID) + await store.receive(\.inner, .myPerspectiveResponse(myPerspectiveRequestID, .success([pending]))) + + #expect(store.state.myPerspective?.id == 5) + #expect(store.state.hiddenMyPerspectiveRow?.statusBadgeTitle == "검수중") + #expect(store.state.hasPublishedPerspective == true) + } + + @Test + func 의견_필수_참여_완료면_내_의견이_비어도_입력창을_다시_열지_않는다() async throws { + var state = battleState(room: .mocks[0]) + state.loadRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [], nextCursor: nil, hasNext: false), + myPerspectives: [] + ) + } + store.exhaustivity = .off + + await store.send(.inner(.progressResponse( + requestID, + .success(.init( + session: session(step: .completed, commentRequired: true, participationCompleted: true), + voteHistory: nil + )) + ))) + let myPerspectiveRequestID = try #require(store.state.myPerspectiveRequestID) + await store.receive(\.inner, .myPerspectiveResponse(myPerspectiveRequestID, .success([]))) + + #expect(store.state.hasPublishedPerspective == true) + #expect(!store.state.shouldShowComposer) + } + + @Test + func 판정이_끝난_뒤_내_의견_조회가_실패하면_목록_오류_문구를_덮지_않는다() async { + var state = completedState(perspectives: []) + state.hasPublishedPerspective = true + state.perspectiveErrorMessage = "목록 오류" + state.myPerspectiveRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.inner(.myPerspectiveResponse(requestID, .failure(.invalidResponse)))) + + #expect(store.state.perspectiveErrorMessage == "목록 오류") + #expect(store.state.hasPublishedPerspective == true) + } + + @Test + func 판정_전에_내_의견_조회가_실패하면_재시도_문구를_보여_준다() async { + var state = completedState(perspectives: []) + state.hasPublishedPerspective = nil + state.myPerspectiveRequestID = requestID + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.inner(.myPerspectiveResponse(requestID, .failure(.invalidResponse)))) + + #expect(store.state.perspectiveErrorMessage == ClassBattleError.invalidResponse.message) + #expect(store.state.hasPublishedPerspective == nil) + } + + @Test + func 내_의견_상세에서_돌아와_내_의견이_비면_입력창을_다시_연다() async throws { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [], nextCursor: nil, hasNext: false), + myPerspectives: [] + ) + } + store.exhaustivity = .off + + await store.send(.view(.perspectiveTapped(5))) + await store.send(.view(.onAppear)) + #expect(store.state.hasPublishedPerspective == nil) + let myPerspectiveRequestID = try #require(store.state.myPerspectiveRequestID) + await store.receive(\.inner, .myPerspectiveResponse(myPerspectiveRequestID, .success([]))) + + #expect(store.state.hasPublishedPerspective == false) + #expect(store.state.shouldShowComposer) + } + + @Test + func 남의_의견_상세에서_돌아오면_내_의견이_비어도_참여_완료를_유지한다() async throws { + var state = completedState(perspectives: [ + perspectiveItem(id: 5, isMine: true), + perspectiveItem(id: 9, isMine: false), + ]) + state.hasPublishedPerspective = true + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub( + spy: ClassBattleFlowSpy(), + perspectivesPage: .init(items: [], nextCursor: nil, hasNext: false), + myPerspectives: [] + ) + } + store.exhaustivity = .off + + // 내 의견을 누른 뒤 복귀 처리 없이 남의 의견을 누르면 앞의 표시가 남지 않아야 한다. + await store.send(.view(.perspectiveTapped(5))) + await store.send(.view(.perspectiveTapped(9))) + await store.send(.view(.onAppear)) + #expect(store.state.hasPublishedPerspective == true) + let myPerspectiveRequestID = try #require(store.state.myPerspectiveRequestID) + await store.receive(\.inner, .myPerspectiveResponse(myPerspectiveRequestID, .success([]))) + + #expect(store.state.hasPublishedPerspective == true) + #expect(!store.state.shouldShowComposer) + } + + @Test + func 변경_요청_중에_알림을_확인하면_요청을_보내지_않고_대상을_비운다() async { + var state = completedState(perspectives: [perspectiveItem(id: 5, isMine: true)]) + state.hasPublishedPerspective = true + state.mutationRequestID = requestID + state.deleteTargetPerspectiveID = 5 + state.customAlert = .deletePerspective() + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } + store.exhaustivity = .off + + await store.send(.scope(.customAlert(.presented(.confirmTapped)))) + + #expect(store.state.customAlert == nil) + #expect(store.state.deleteTargetPerspectiveID == nil) + #expect(store.state.mutationRequestID == requestID) + #expect(store.state.perspectives.map(\.id) == [5]) + } + + @Test + func 목록과_내_의견이_모두_실패한_뒤_다시_시도하면_목록도_다시_불러온다() async { + var state = completedState(perspectives: []) + state.hasPublishedPerspective = nil + state.perspectiveErrorMessage = "목록 오류" + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: ClassBattleFlowSpy()) + } + store.exhaustivity = .off + + await store.send(.view(.retryMyPerspectiveTapped)) + + #expect(store.state.perspectiveRequestID != nil) + #expect(store.state.isLoadingPerspectives) + #expect(store.state.myPerspectiveRequestID != nil) + #expect(store.state.perspectiveErrorMessage == nil) + } + + @Test + func 목록이_있으면_다시_시도해도_내_의견만_다시_불러오고_목록은_유지한다() async { + var state = completedState(perspectives: [perspectiveItem(id: 9, isMine: false)]) + state.hasPublishedPerspective = nil + let store = TestStore(initialState: state) { ClassBattleFlowFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classBattleUseCase = ClassBattleFlowUseCaseStub(spy: ClassBattleFlowSpy()) + } + store.exhaustivity = .off + + await store.send(.view(.retryMyPerspectiveTapped)) + + #expect(store.state.perspectiveRequestID == nil) + #expect(store.state.perspectives.map(\.id) == [9]) + #expect(store.state.myPerspectiveRequestID != nil) + } + + private func completedState(perspectives: [ClassPerspectiveItem]) -> ClassBattleFlowFeature.State { + var state = battleState(room: .mocks[0]) + state.session = session(step: .completed, commentRequired: false) + state.viewState = .loaded + state.voteStats = .init(options: [], totalCount: 0, updatedAt: .now) + state.hasPublishedPerspective = false + state.perspectives = perspectives + return state + } + + private func perspectiveItem( + id: Int, + isMine: Bool?, + status: String? = nil + ) -> ClassPerspectiveItem { + ClassPerspectiveItem( + id: id, + author: .init(userTag: "u\(id)", nickname: "작성자 \(id)", characterType: nil, characterImageURL: nil), + option: .init(id: 1, label: "A", title: "찬성", stance: "찬성"), + content: "의견 \(id)", + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: isMine, + status: status, + createdAt: .now + ) + } + private func session( step: ClassBattleStep, - commentRequired: Bool + commentRequired: Bool, + midVoteEnabled: Bool = false, + participationCompleted: Bool = false ) -> ClassBattleSession { ClassBattleSession( classID: 1, @@ -861,7 +1699,7 @@ struct ClassBattleFlowTests { closed: false, commentRequired: commentRequired, canParticipate: true, - participationCompleted: false, + participationCompleted: participationCompleted, battle: ClassBattleDetail( id: 10, title: "토론", @@ -874,7 +1712,8 @@ struct ClassBattleFlowTests { participantsCount: 1, audioDuration: 30, options: [] - ) + ), + midVoteEnabled: midVoteEnabled ) } @@ -892,12 +1731,18 @@ private actor ClassBattleFlowSpy { private(set) var scenarioBattleIDs: [Int] = [] private(set) var loadedURLs: [URL] = [] private(set) var timeSubscriptions = 0 + private(set) var midVoteArguments: [(Int, Int, Int)] = [] + private(set) var postVoteOptionIDs: [Int] = [] func recordClass(_ id: Int) { classIDs.append(id) } func recordVoteHistory(_ id: Int) { voteHistoryClassIDs.append(id) } func recordScenario(_ id: Int) { scenarioBattleIDs.append(id) } func recordAudio(_ url: URL) { loadedURLs.append(url) } func recordTimeSubscription() { timeSubscriptions += 1 } + func recordMidVote(classID: Int, checkpointID: Int, optionID: Int) { + midVoteArguments.append((classID, checkpointID, optionID)) + } + func recordPostVote(optionID: Int) { postVoteOptionIDs.append(optionID) } } private struct ClassBattleFlowUseCaseStub: ClassBattleInterface { @@ -906,6 +1751,7 @@ private struct ClassBattleFlowUseCaseStub: ClassBattleInterface { var history: ClassVoteHistory? var perspectivesPage: ClassPerspectivePage? var failsLoad = false + var myPerspectives: [ClassPerspectiveItem]? func fetchBattle(classID: Int) async throws -> ClassBattleSession { await spy.recordClass(classID) @@ -921,8 +1767,19 @@ private struct ClassBattleFlowUseCaseStub: ClassBattleInterface { } func submitPreVote(classID _: Int, optionID _: Int) async throws -> ClassVoteReceipt { throw ClassBattleError.invalidResponse } + func submitMidVote( + classID: Int, + checkpointID: Int, + optionID: Int + ) async throws -> ClassVoteReceipt { + await spy.recordMidVote(classID: classID, checkpointID: checkpointID, optionID: optionID) + return ClassVoteReceipt(voteID: 1, status: .ttsListening) + } func completeTTS(classID _: Int) async throws { throw ClassBattleError.invalidResponse } - func submitPostVote(classID _: Int, optionID _: Int) async throws -> ClassVoteReceipt { throw ClassBattleError.invalidResponse } + func submitPostVote(classID _: Int, optionID: Int) async throws -> ClassVoteReceipt { + await spy.recordPostVote(optionID: optionID) + throw ClassBattleError.invalidResponse + } func fetchVoteStats(classID _: Int) async throws -> ClassVoteStats { throw ClassBattleError.invalidResponse } func publishPerspective(classID _: Int, content _: String) async throws -> ClassPerspectiveReceipt { throw ClassBattleError.invalidResponse } func fetchPerspectives( @@ -935,7 +1792,112 @@ private struct ClassBattleFlowUseCaseStub: ClassBattleInterface { guard let perspectivesPage else { throw ClassBattleError.invalidResponse } return perspectivesPage } - func fetchMyPerspectives(classID _: Int) async throws -> [ClassPerspectiveItem] { throw ClassBattleError.invalidResponse } + func fetchMyPerspectives(classID _: Int) async throws -> [ClassPerspectiveItem] { + guard let myPerspectives else { throw ClassBattleError.invalidResponse } + return myPerspectives + } +} + +/// 신고 요청을 멈춰 두었다가 풀어 주는 관문. +private actor ReportGate { + private var started = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseContinuation: CheckedContinuation? + + func wait() async { + started = true + startWaiters.forEach { $0.resume() } + startWaiters = [] + await withCheckedContinuation { releaseContinuation = $0 } + } + + func waitForStart() async { + if started { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func release() { + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +/// 신고만 관문에서 멈추고 나머지는 Mock에 맡기는 의견 유스케이스. +private struct SuspendedReportPerspective: PerspectiveInterface { + let gate: ReportGate + private let base = MockPerspectiveRepository() + + init(gate: ReportGate) { + self.gate = gate + } + + func fetchPerspective(perspectiveId: Int) async throws -> BattlePerspective { + try await base.fetchPerspective(perspectiveId: perspectiveId) + } + + func fetchLabeledComments( + perspectiveId: Int, + cursor: String?, + size: Int? + ) async throws -> PerspectiveCommentPage { + try await base.fetchLabeledComments(perspectiveId: perspectiveId, cursor: cursor, size: size) + } + + func createComment( + perspectiveId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + try await base.createComment(perspectiveId: perspectiveId, content: content) + } + + func updateComment( + perspectiveId: Int, + commentId: Int, + content: String + ) async throws -> PerspectiveCommentMutationResult { + try await base.updateComment(perspectiveId: perspectiveId, commentId: commentId, content: content) + } + + func deleteComment( + perspectiveId: Int, + commentId: Int + ) async throws { + try await base.deleteComment(perspectiveId: perspectiveId, commentId: commentId) + } + + func updatePerspective( + perspectiveId: Int, + content: String + ) async throws { + try await base.updatePerspective(perspectiveId: perspectiveId, content: content) + } + + func deletePerspective(perspectiveId: Int) async throws { + try await base.deletePerspective(perspectiveId: perspectiveId) + } + + func likePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + try await base.likePerspective(perspectiveId: perspectiveId) + } + + func unlikePerspective(perspectiveId: Int) async throws -> CommentLikeResult { + try await base.unlikePerspective(perspectiveId: perspectiveId) + } + + func fetchPerspectiveLikes(perspectiveId: Int) async throws -> CommentLikeResult { + try await base.fetchPerspectiveLikes(perspectiveId: perspectiveId) + } + + func reportPerspective(perspectiveId _: Int) async throws { + await gate.wait() + } + + func reportComment( + perspectiveId: Int, + commentId: Int + ) async throws { + try await base.reportComment(perspectiveId: perspectiveId, commentId: commentId) + } } private struct ClassBattleFlowScenarioStub: BattleInterface { diff --git a/Projects/Feature/Class/Tests/Sources/ClassMyOpinionsTests.swift b/Projects/Feature/Class/Tests/Sources/ClassMyOpinionsTests.swift index 2befa8cf..48066429 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassMyOpinionsTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassMyOpinionsTests.swift @@ -83,9 +83,9 @@ struct ClassMyOpinionsTests { let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } await store.send(.inner(.perspectivesResponse(.success([item])))) { $0.isLoading = false - $0.opinions = [ClassReportOpinion( + $0.perspectiveOpinions = [ClassReportOpinion( id: "77", - kind: .unspecified, + kind: .comment, body: "내 생각", stance: "찬성", likeCount: 4, @@ -93,6 +93,210 @@ struct ClassMyOpinionsTests { authorName: "나" )] } - #expect(store.state.unspecified.count == 1) + #expect(store.state.comments.count == 1) + } + + @Test + func 서버_내_의견은_검수_중과_거절만_빼고_알_수_없는_상태는_공개로_본다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.isLoading = true + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.perspectivesResponse(.success([ + perspective(id: 1, status: "PUBLISHED"), + perspective(id: 2, status: "PENDING"), + perspective(id: 3, status: "rejected"), + perspective(id: 4, status: nil), + perspective(id: 5, status: "HIDDEN_BY_ADMIN"), + ])))) + + #expect(store.state.perspectiveOpinions.map(\.id) == ["1", "4", "5"]) + } + + @Test + func 내_활동_댓글은_원_의견_제목과_내_이름을_가진_대댓글로_옮긴다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.myDisplayNameResponse(.success("민지")))) + await store.receive(\.async.fetchReplyPage) + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: "c-2"))))) + + #expect(store.state.replyOpinions == [ClassReportOpinion( + id: "reply-71", + kind: .reply, + body: "댓글 71", + likeCount: 2, + authorName: "민지", + parent: ClassReportReplyContext(body: "원 의견 71") + )]) + #expect(store.state.replies.first?.replyAuthorLabel == "민지의 답글") + #expect(store.state.replies.first?.parentStanceLabel == nil) + #expect(store.state.nextCursor == "c-2") + #expect(store.state.canLoadMoreReplies) + } + + @Test + func 표시_이름이_비면_나로_표시한다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.myDisplayName = "" + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: nil))))) + + #expect(store.state.replies.first?.replyAuthorLabel == "나의 답글") + } + + @Test + func 의견과_대댓글_응답은_도착_순서와_무관하게_서로를_덮어쓰지_않는다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.isLoading = true + state.myDisplayName = "민지" + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: nil))))) + await store.send(.inner(.perspectivesResponse(.success([perspective(id: 1, status: nil)])))) + + #expect(store.state.comments.map(\.opinion.id) == ["1"]) + #expect(store.state.replies.map(\.opinion.id) == ["reply-71"]) + #expect(!store.state.isEmpty) + } + + @Test + func 목록_끝에서_커서로_다음_대댓글을_이어_붙이고_첫_페이지는_교체한다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.myDisplayName = "민지" + let store = TestStore(initialState: state) { + ClassMyOpinionsFeature() + } withDependencies: { + $0.classResultUseCase = ActivityStub(pages: ["c-2": activity(ids: [72], next: nil)]) + } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: "c-2"))))) + await store.send(.view(.repliesEndReached)) + await store.receive(\.async.fetchReplyPage) + #expect(store.state.isLoadingMore) + await store.receive(\.inner.replyPageResponse) + + #expect(store.state.replyOpinions.map(\.id) == ["reply-71", "reply-72"]) + #expect(!store.state.canLoadMoreReplies) + #expect(!store.state.isLoadingMore) + + await store.send(.inner(.replyPageResponse(cursor: nil, .success(activity(ids: [71], next: nil))))) + #expect(store.state.replyOpinions.map(\.id) == ["reply-71"]) + } + + @Test + func 내_활동_조회에_실패하면_의견은_유지하고_토스트를_띄운다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.perspectiveOpinions = [ClassReportOpinion(id: "1", kind: .comment, body: "내 의견")] + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: nil, .failure(.network("실패"))))) + #expect(store.state.toastMessage == "정보를 불러오지 못했어요") + #expect(store.state.perspectiveOpinions.count == 1) + + await store.send(.view(.toastShown)) + #expect(store.state.toastMessage == nil) + + await store.send(.inner(.myDisplayNameResponse(.failure(.network("실패"))))) + #expect(store.state.toastMessage == "정보를 불러오지 못했어요") + } + + @Test + func 다음_대댓글_페이지_조회에_실패하면_더_불러오기를_멈춘다() async { + var state = ClassMyOpinionsFeature.State() + state.classID = 31 + state.nextCursor = "c-2" + state.hasNext = true + state.isLoadingMore = true + let store = TestStore(initialState: state) { ClassMyOpinionsFeature() } + store.exhaustivity = .off + + await store.send(.inner(.replyPageResponse(cursor: "c-2", .failure(.network("실패"))))) + + #expect(!store.state.hasNext) + #expect(!store.state.canLoadMoreReplies) + #expect(store.state.toastMessage == "정보를 불러오지 못했어요") + } + + private func perspective( + id: Int, + status: String? + ) -> ClassPerspectiveItem { + ClassPerspectiveItem( + id: id, + author: .init(userTag: "member", nickname: "나", characterType: nil, characterImageURL: nil), + option: .init(id: 1, label: "A", title: "찬성", stance: "찬성"), + content: "의견 \(id)", + likeCount: 0, + commentCount: 0, + isLiked: false, + isMyPerspective: true, + status: status, + createdAt: .now + ) + } + + private func activity( + ids: [Int], + next: String? + ) -> ClassMyActivity { + ClassMyActivity( + classID: 31, + receivedLikeCount: 0, + commentCount: ids.count, + comments: ids.map { + ClassMyActivityComment( + id: $0, + perspectiveID: $0 + 100, + perspectiveTitle: "원 의견 \($0)", + stance: "", + content: "댓글 \($0)", + likeCount: 2, + createdAt: nil + ) + }, + nextCursor: next, + hasNext: next != nil + ) + } + + private struct ActivityStub: ClassResultInterface { + let pages: [String: ClassMyActivity] + + func fetchResult(roomID _: Int) async throws -> ClassResult { + throw ClassError.network("사용하지 않음") + } + + func fetchPersonalResult(roomID _: Int) async throws -> ClassAIPersonalFeedback { + throw ClassAIFeedbackError.temporarilyUnavailable + } + + func fetchInsight(roomID _: Int) async throws -> ClassAIInsight { + throw ClassAIFeedbackError.temporarilyUnavailable + } + + func retryInsight(roomID _: Int) async throws {} + + func fetchMyActivity( + roomID _: Int, + cursor: String? + ) async throws -> ClassMyActivity { + guard let cursor, let page = pages[cursor] else { throw ClassError.network("페이지 없음") } + return page + } } } diff --git a/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift b/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift index d66a31c2..6850a659 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassOwnerDashboardTests.swift @@ -258,6 +258,8 @@ struct ClassOwnerDashboardTests { let result: Result var feedback: ClassAIPersonalFeedback? = nil var insight: ClassAIInsight? = nil + var myActivity: ClassMyActivity? = nil + var suspendsMyActivity = false func fetchResult(roomID _: Int) async throws -> ClassResult { try result.get() @@ -276,6 +278,15 @@ struct ClassOwnerDashboardTests { func retryInsight(roomID _: Int) async throws { throw ClassAIFeedbackError.temporarilyUnavailable } + + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + if suspendsMyActivity { try await Task.never() } + guard let myActivity else { throw ClassAIFeedbackError.temporarilyUnavailable } + return myActivity + } } private func serverResult( @@ -376,6 +387,163 @@ struct ClassOwnerDashboardTests { await store.receive(\.delegate.openMyOpinions) } + @Test + func 참여_운영자의_받은_추천은_내_활동_응답으로_표시한다() async { + let room = ClassRoom.mocks[0] + let requestID = UUID() + var state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + state.requestID = requestID + let result = ClassResult( + classID: room.id, + closed: true, + closeAt: room.deadline, + stats: serverResult(room).stats, + myReportState: "SUCCEEDED", + insightState: nil + ) + let activity = ClassMyActivity( + classID: room.id, + receivedLikeCount: 12, + commentCount: 3, + comments: [], + nextCursor: nil, + hasNext: false + ) + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(result), + myActivity: activity + ) + } + store.exhaustivity = .off + #expect(store.state.personalReceivedLikeCountText == "—") + + await store.send(.inner(.resultResponse( + instanceID: state.instanceID, + requestID: requestID, + roomID: room.id, + .success(result) + ))) + await store.receive(\.inner.myActivityResponse) + + #expect(store.state.receivedLikeCount == 12) + #expect(store.state.personalReceivedLikeCountText == "12") + #expect(store.state.myActivityRequestID == nil) + } + + @Test + func 내_활동_조회가_실패하면_받은_추천을_대시로_표시한다() async { + let room = ClassRoom.mocks[0] + let requestID = UUID() + var state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + state.requestID = requestID + let result = ClassResult( + classID: room.id, + closed: true, + closeAt: room.deadline, + stats: serverResult(room).stats, + myReportState: "SUCCEEDED", + insightState: nil + ) + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub(result: .success(result)) + } + store.exhaustivity = .off + #expect(store.state.personalReceivedLikeCountText == "—") + + await store.send(.inner(.resultResponse( + instanceID: state.instanceID, + requestID: requestID, + roomID: room.id, + .success(result) + ))) + await store.receive(\.inner.myActivityResponse) + + #expect(store.state.receivedLikeCount == nil) + #expect(store.state.personalReceivedLikeCountText == "—") + #expect(store.state.myActivityRequestID == nil) + } + + @Test + func 뒤로_가면_진행중인_내_활동_조회를_취소한다() async { + let room = ClassRoom.mocks[0] + let state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(serverResult(room)), + suspendsMyActivity: true + ) + } + store.exhaustivity = .off + + await store.send(.async(.fetchMyActivity)) + #expect(store.state.myActivityRequestID != nil) + await store.send(.view(.backTapped)) + await store.finish(timeout: .seconds(1)) + + #expect(store.state.myActivityRequestID == nil) + } + + @Test + func 다시_불러오면_받은_추천을_비우고_이전_내_활동_응답을_무시한다() async throws { + let room = ClassRoom.mocks[0] + var state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + state.receivedLikeCount = 12 + let activity = ClassMyActivity( + classID: room.id, + receivedLikeCount: 30, + commentCount: 0, + comments: [], + nextCursor: nil, + hasNext: false + ) + let store = TestStore(initialState: state) { ClassOwnerDashboardFeature() } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(serverResult(room)), + suspendsMyActivity: true + ) + } + store.exhaustivity = .off + + await store.send(.async(.fetchMyActivity)) + let staleRequestID = try #require(store.state.myActivityRequestID) + await store.send(.view(.retryTapped)) + await store.receive(\.async.fetchDashboard) + #expect(store.state.receivedLikeCount == nil) + #expect(store.state.myActivityRequestID == nil) + await store.receive(\.inner.resultResponse) + await store.send(.inner(.myActivityResponse( + instanceID: state.instanceID, + requestID: staleRequestID, + roomID: room.id, + .success(activity) + ))) + #expect(store.state.receivedLikeCount == nil) + await store.finish(timeout: .seconds(1)) + } + + @Test + func 개인_피드백이_없으면_내가_남긴_의견_버튼에_개수를_표시하지_않는다() { + let room = ClassRoom.mocks[0] + var state = ClassOwnerDashboardFeature.State(room: room, instanceID: UUID()) + #expect(state.myOpinionsTitle == "내가 남긴 의견 0개 보기") + state.classResult = serverResult(room) + #expect(state.myOpinionsTitle == "내가 남긴 의견 보기") + state.generatedFeedback = ClassAIPersonalFeedback( + summaryTitle: "개인 결과", + summaryDetail: "참여 분석", + points: [], + nextQuestion: "다음 질문", + nextHint: "", + perspectiveCount: 2 + ) + #expect(state.myOpinionsTitle == "내가 남긴 의견 2개 보기") + } + @Test func 서버_현황_표시값은_State에서_계산한다() { let room = ClassRoom.mocks[0] diff --git a/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift b/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift index 9fe667c8..e060ead8 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassReportTests.swift @@ -28,16 +28,26 @@ struct ClassReportTests { ]) } + @Test + func 주요_의견_본문은_따옴표로_감싸_표시한다() { + let row = ClassReportHighlightRow(id: 0, title: "주요 의견 1", text: "첫 인용", isLast: true) + + #expect(row.quotedText == "“첫 인용”") + } + private struct ResultStub: ClassResultInterface { let result: Result let feedback: ClassAIPersonalFeedback? + let myActivity: ClassMyActivity? init( result: Result, - feedback: ClassAIPersonalFeedback? = nil + feedback: ClassAIPersonalFeedback? = nil, + myActivity: ClassMyActivity? = nil ) { self.result = result self.feedback = feedback + self.myActivity = myActivity } func fetchResult(roomID _: Int) async throws -> ClassResult { @@ -56,25 +66,39 @@ struct ClassReportTests { func retryInsight(roomID _: Int) async throws { throw ClassAIFeedbackError.temporarilyUnavailable } + + func fetchMyActivity( + roomID _: Int, + cursor _: String? + ) async throws -> ClassMyActivity { + guard let myActivity else { throw ClassError.network("내 활동을 불러오지 못했습니다.") } + return myActivity + } } private func result( room: ClassRoom, reportState: String?, - postVoteRatio: Double = 54.5 + postVoteRatio: Double = 54.5, + activeMemberCount: Int = 12, + firstCount: Int = 6, + secondCount: Int = 5 ) -> ClassResult { ClassResult( classID: room.id, closed: true, closeAt: room.deadline, stats: .init( - activeMemberCount: 12, - completedCount: 10, + activeMemberCount: activeMemberCount, + completedCount: min(10, activeMemberCount), participationRate: 83.3, - commentWriterCount: 9, + commentWriterCount: min(9, activeMemberCount), commentRate: 75, - postVoteCount: 11, - options: [.init(id: 31, title: "창작이다", preVoteCount: 8, postVoteCount: 6, postVoteRatio: postVoteRatio)] + postVoteCount: firstCount + secondCount, + options: [ + .init(id: 31, title: "창작이다", preVoteCount: 8, postVoteCount: firstCount, postVoteRatio: postVoteRatio), + .init(id: 32, title: "아니다", preVoteCount: 4, postVoteCount: secondCount, postVoteRatio: 100 - postVoteRatio) + ] ), myReportState: reportState, insightState: nil @@ -93,27 +117,49 @@ struct ClassReportTests { #expect(state.serverCommentRate == "75%") #expect(state.serverParticipationCount == "10 / 12명") #expect(state.serverCommentCount == "9 / 12명") - #expect(abs(state.serverParticipationProgress - 0.833) < 0.0001) + #expect(abs(state.serverParticipationProgress - 0.833) < 0.001) #expect(abs(state.serverCommentProgress - 0.75) < 0.0001) let option = try #require(state.classResult?.stats.options.first) #expect(state.serverOptionCounts[option.id] == "사전 8 · 사후 6") #expect(state.serverDistributionTotalText == "11명") - #expect(abs(state.serverDistributionProgress - 0.545) < 0.0001) + #expect(abs(state.serverDistributionProgress - (6.0 / 11.0)) < 0.0001) #expect(state.serverDistributionOptionCountTexts[option.id] == "6명") #expect(state.serverDistributionOptionRatioTexts[option.id] == "54.5%") + #expect(state.serverDistributionOptionRatioTexts[32] == "45.5%") state.classResult = result( room: .mockClosedSociety, reportState: "SUCCEEDED", postVoteRatio: -10 ) - #expect(state.serverDistributionProgress == 0) + #expect(abs(state.serverDistributionProgress - (6.0 / 11.0)) < 0.0001) state.classResult = result( room: .mockClosedSociety, reportState: "SUCCEEDED", postVoteRatio: 110 ) - #expect(state.serverDistributionProgress == 1) + #expect(abs(state.serverDistributionProgress - 0.545) < 0.001) + } + + @Test + func 참여_대상과_사후_투표가_없으면_미참여_상태를_표시한다() { + var state = ClassReportFeature.State(room: .mockClosedSociety, instanceID: UUID()) + state.classResult = result( + room: .mockClosedSociety, + reportState: "SUCCEEDED", + activeMemberCount: 0, + firstCount: 0, + secondCount: 0 + ) + #expect(state.serverParticipationRate == "아직 참여 없음") + #expect(state.serverCommentRate == "아직 참여 없음") + #expect(state.serverParticipationProgress == 0) + #expect(state.serverCommentProgress == 0) + #expect(state.serverDistributionTotalCount == 0) + #expect(state.serverDistributionTotalText == "0명") + #expect(state.serverDistributionProgress == 0) + #expect(state.serverDistributionOptionRatioTexts[31] == "아직 참여 없음") + #expect(state.serverDistributionOptionRatioTexts[32] == "아직 참여 없음") } @Test @@ -358,4 +404,86 @@ struct ClassReportTests { await store.receive(\.inner.resultResponse) #expect(store.state.sourceError == .resultExpired) } + + @Test + func 참여자_리포트의_받은_추천은_내_활동_응답으로_표시한다() async { + let room = ClassRoom.mockClosedSociety + let activity = ClassMyActivity( + classID: room.id, + receivedLikeCount: 12, + commentCount: 3, + comments: [], + nextCursor: nil, + hasNext: false + ) + let store = TestStore(initialState: ClassReportFeature.State(room: room, instanceID: UUID())) { + ClassReportFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(result(room: room, reportState: "SUCCEEDED")), + myActivity: activity + ) + } + store.exhaustivity = .off + + #expect(store.state.personalReceivedLikeCountText == "—") + await store.send(.view(.onAppear)) + await store.receive(\.async.fetchMyActivity) + await store.receive(\.inner.myActivityResponse) + + #expect(store.state.receivedLikeCount == 12) + #expect(store.state.personalReceivedLikeCountText == "12") + #expect(store.state.myActivityRequestID == nil) + + await store.send(.async(.fetch)) + #expect(store.state.receivedLikeCount == nil) + } + + @Test + func 내_활동_조회에_실패하면_받은_추천을_대시로_둔다() async { + let room = ClassRoom.mockClosedSociety + let store = TestStore(initialState: ClassReportFeature.State(room: room, instanceID: UUID())) { + ClassReportFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub(result: .success(result(room: room, reportState: "SUCCEEDED"))) + } + store.exhaustivity = .off + + await store.send(.view(.onAppear)) + await store.receive(\.inner.myActivityResponse) + + #expect(store.state.receivedLikeCount == nil) + #expect(store.state.personalReceivedLikeCountText == "—") + } + + @Test + func 개인_리포트_권한이_없으면_내_활동을_조회하지_않는다() async { + let room = ClassRoom.mockClosedSociety + let store = TestStore(initialState: ClassReportFeature.State(room: room, instanceID: UUID())) { + ClassReportFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.classResultUseCase = ResultStub( + result: .success(result(room: room, reportState: nil)), + myActivity: ClassMyActivity( + classID: room.id, + receivedLikeCount: 12, + commentCount: 0, + comments: [], + nextCursor: nil, + hasNext: false + ) + ) + } + store.exhaustivity = .off + + await store.send(.view(.onAppear)) + await store.receive(\.inner.resultResponse) + await store.finish() + + // 조회했다면 대역이 돌려주는 12가 들어간다. + #expect(store.state.receivedLikeCount == nil) + } } diff --git a/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift b/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift index c8513f75..b6ca8b74 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassSettingTests.swift @@ -56,6 +56,61 @@ struct ClassSettingTests { #expect(ai.aiCreation?.ownerParticipates == true) } + @Test + func 생성_요청_키는_같은_내용의_재시도에_유지되고_내용을_바꾸면_바뀐다() throws { + let instanceID = try #require(UUID(uuidString: "3F2B8C1E-7D4A-4F6B-9A0E-2C5D8E1F4A7B")) + var state = ClassSettingFeature.State( + battle: battle, + deadline: deadline, + instanceID: instanceID + ) + state.name = "토론 클래스" + let firstKey = state.creation?.requestKey + state.name = "토론 클래스 " + + #expect(firstKey?.count == 64) + #expect(state.creation?.requestKey == firstKey) + + state.name = "다른 토론 클래스" + #expect(state.creation?.requestKey != firstKey) + state.name = "토론 클래스" + #expect(state.creation?.requestKey == firstKey) + + state.deadline = deadline.addingTimeInterval(60) + #expect(state.creation?.requestKey != firstKey) + state.deadline = deadline + state.isDeadlineEnabled = false + #expect(state.creation?.requestKey != firstKey) + state.isDeadlineEnabled = true + + state.requiresComment = true + #expect(state.creation?.requestKey != firstKey) + state.requiresComment = false + state.ownerParticipates = true + #expect(state.creation?.requestKey != firstKey) + state.ownerParticipates = false + #expect(state.creation?.requestKey == firstKey) + + var other = ClassSettingFeature.State(battle: battle, deadline: deadline) + other.name = state.name + #expect(other.creation?.requestKey != firstKey) + + state.aiQuestion = ClassAIQuestion(version: topicVersion) + state.topicVersionID = topicVersion.id + #expect(state.aiCreation?.requestKey != firstKey) + #expect(state.creation?.requestKey == firstKey) + + state.battle = nil + let aiKey = state.creation?.requestKey + #expect(aiKey == state.aiCreation?.requestKey) + #expect(aiKey != firstKey) + state.topicVersionID = topicVersion.id + 1 + #expect(state.creation?.requestKey != aiKey) + + state.battle = ClassBattleSummary.mocks[1] + #expect(state.creation?.requestKey != firstKey) + } + @Test func 클래스를_만들면_생성_델리게이트를_보낸다() async throws { var state = ClassSettingFeature.State(battle: battle, deadline: deadline) diff --git a/Projects/Feature/Class/Tests/Sources/ClassTests.swift b/Projects/Feature/Class/Tests/Sources/ClassTests.swift index a5ffd5eb..da6bc93d 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassTests.swift @@ -8,6 +8,7 @@ @testable import Class import ClassDomainInterface import ComposableArchitecture +import Foundation import Testing @MainActor @@ -43,15 +44,113 @@ struct ClassTests { } @Test - func 새_클래스_만들기_탭하면_생성_델리게이트를_보낸다() async { + func 새_클래스_만들기는_사용_중인_이용권이면_생성_델리게이트를_보낸다() async { let store = TestStore(initialState: ClassIntroFeature.State()) { ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .success(introTicket(.active))) } - await store.send(.view(.createTapped)) + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { $0.isCheckingTicket = false } await store.receive(\.delegate.create) } + @Test + func 새_클래스_만들기는_이용권이_만료되면_등록_안내를_띄우고_등록하기로_이동한다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .success(introTicket(.expired))) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { + $0.isCheckingTicket = false + $0.ticketNotice = .expired + $0.customAlert = .classTicketExpired() + } + await store.send(.customAlert(.presented(.confirmTapped))) { + $0.ticketNotice = nil + $0.customAlert = nil + } + await store.receive(\.delegate.registerTicket) + } + + @Test + func 새_클래스_만들기는_만료_안내에서_뒤로가기를_누르면_머문다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .success(introTicket(.expired))) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { + $0.isCheckingTicket = false + $0.ticketNotice = .expired + $0.customAlert = .classTicketExpired() + } + await store.send(.customAlert(.presented(.cancelTapped))) { + $0.ticketNotice = nil + $0.customAlert = nil + } + } + + @Test + func 새_클래스_만들기는_이용권이_없으면_안내만_띄운다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .failure(.notFound)) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { + $0.isCheckingTicket = false + $0.ticketNotice = .required + $0.customAlert = .classTicketRequired() + } + await store.send(.customAlert(.presented(.confirmTapped))) { + $0.ticketNotice = nil + $0.customAlert = nil + } + } + + @Test + func 새_클래스_만들기에서_이용권_조회가_실패하면_토스트를_띄운다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .failure(.network("연결 실패"))) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.receive(\.inner.ticketResponse) { + $0.isCheckingTicket = false + $0.toastMessage = "정보를 불러오지 못했어요" + } + await store.send(.view(.toastShown)) { $0.toastMessage = nil } + } + + @Test + func 이용권_확인_중_다시_누르면_무시하고_뒤로_가면_확인을_취소한다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicketFetch: { + try await Task.sleep(for: .seconds(60)) + throw ClassTicketError.notFound + }) + } + + await store.send(.view(.createTapped)) { $0.isCheckingTicket = true } + await store.send(.view(.createTapped)) + await store.send(.view(.backTapped)) { $0.isCheckingTicket = false } + await store.receive(\.delegate.backToHome) + await store.finish(timeout: .seconds(1)) + } + @Test func 이용권_탭하면_등록_화면으로_이동한다() async { let store = TestStore(initialState: ClassIntroFeature.State()) { @@ -118,3 +217,15 @@ struct ClassTests { )) } } + +private func introTicket(_ status: ClassTicket.Status) -> ClassTicket { + ClassTicket( + id: 3, + capacity: 15, + status: status, + activatedAt: Date(timeIntervalSince1970: 1_780_000_000), + expiresAt: Date(timeIntervalSince1970: 1_795_000_000), + remainingDays: 0, + readGraceEndsAt: Date(timeIntervalSince1970: 1_797_592_000) + ) +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift b/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift index 77cbc8d1..98fa35ba 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift @@ -28,7 +28,7 @@ struct ClassTicketTests { $0.shouldCheckExistingTicket = false $0.isLoadingExistingTicket = true } - await store.receive(\.myTicketResponse) { + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false $0.ticket = ticket $0.registrationStartDate = start @@ -63,7 +63,7 @@ struct ClassTicketTests { } #expect(!store.state.canCheckCode) await gate.complete(ticket) - await store.receive(\.myTicketResponse) { + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false $0.ticket = ticket $0.registrationStartDate = ticket.activatedAt @@ -82,14 +82,14 @@ struct ClassTicketTests { $0.shouldCheckExistingTicket = false $0.isLoadingExistingTicket = true } - await store.receive(\.myTicketResponse) { $0.isLoadingExistingTicket = false } + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false } #expect(store.state.phase == .entry) - #expect(store.state.errorMessage == nil) + #expect(store.state.customAlert == nil) await store.send(.view(.onAppear)) } @Test - func 만료된_이용권은_사용_중으로_표시하지_않는다() async { + func 만료된_이용권은_개요_대신_코드_입력부터_시작한다() async { let ticket = ClassTicket( id: 3, capacity: 15, @@ -107,14 +107,31 @@ struct ClassTicketTests { $0.shouldCheckExistingTicket = false $0.isLoadingExistingTicket = true } - await store.receive(\.myTicketResponse) { + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false } + #expect(store.state.phase == .entry) + #expect(store.state.ticket == nil) + } + + @Test + func 진입_조회가_실패하면_토스트를_띄운_뒤_화면을_닫는다() async { + let store = TestStore(initialState: ClassTicketFeature.State()) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub(myTicket: .failure(.network("연결 실패"))) + } + + await store.send(.view(.onAppear)) { + $0.shouldCheckExistingTicket = false + $0.isLoadingExistingTicket = true + } + await store.receive(\.existingTicketResponse) { $0.isLoadingExistingTicket = false - $0.ticket = ticket - $0.registrationStartDate = ticket.activatedAt - $0.expirationDate = ticket.expiresAt - $0.phase = .overview + $0.toastMessage = "정보를 불러오지 못했어요" + $0.closesAfterToast = true + } + await store.send(.view(.toastShown)) { + $0.toastMessage = nil + $0.closesAfterToast = false } - #expect(store.state.overviewTitle == "만료된 이용권") + await store.receive(\.delegate.dismiss) } @Test @@ -207,15 +224,77 @@ struct ClassTicketTests { $0.isLoadingExistingTicket = true $0.preview = preview } - await store.receive(\.myTicketResponse) { + await store.receive(\.registeredTicketResponse) { $0.isLoadingExistingTicket = false } + await store.receive(\.delegate, .existingTicketFound(ticket)) + } + + @Test + func 내가_등록한_만료_이용권_코드는_만료_안내를_띄운다() async { + let preview = ClassTicketPreview( + capacity: 30, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: true + ) + let ticket = ClassTicket( + id: 3, + capacity: 30, + status: .expired, + activatedAt: Date(timeIntervalSince1970: 1_780_000_000), + expiresAt: Date(timeIntervalSince1970: 1_795_000_000), + remainingDays: 0, + readGraceEndsAt: Date(timeIntervalSince1970: 1_797_592_000) + ) + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub(preview: .success(preview), myTicket: .success(ticket)) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.isLoadingExistingTicket = true + $0.preview = preview + } + await store.receive(\.registeredTicketResponse) { $0.isLoadingExistingTicket = false - $0.ticket = ticket - $0.phase = .overview - $0.registrationStartDate = start - $0.expirationDate = end + $0.registerNotice = .registrationExpired + $0.customAlert = .ticketRegistrationExpired() } } + @Test + func 내_코드_재확인_중_코드를_고치면_조회를_취소한다() async { + let preview = ClassTicketPreview( + capacity: 30, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: true + ) + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub(preview: .success(preview), myTicketFetch: { + try await Task.sleep(for: .seconds(60)) + throw ClassTicketError.notFound + }) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.isLoadingExistingTicket = true + $0.preview = preview + } + await store.send(.binding(.set(\.code, "PICKE-ZZ99"))) { + $0.code = "PICKE-ZZ99" + $0.preview = nil + $0.isLoadingExistingTicket = false + } + await store.finish(timeout: .seconds(1)) + } + @Test func 서버_거절_사유를_보여주고_등록을_막는다() async { var state = ClassTicketFeature.State() @@ -227,24 +306,206 @@ struct ClassTicketTests { await store.send(.view(.checkTapped)) { $0.isChecking = true } await store.receive(\.checkResponse) { $0.isChecking = false - $0.errorMessage = ClassTicketError.registrationExpired.message + $0.registerNotice = .registrationExpired + $0.customAlert = .ticketRegistrationExpired() } #expect(store.state.phase == .entry) } + + @Test + func 다른_계정에_등록된_코드_안내는_배경_탭이면_머물고_뒤로가기면_닫는다() async { + var state = ClassTicketFeature.State() + state.code = "CLAIMED" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub(preview: .failure(.claimed)) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.registerNotice = .alreadyRegistered + $0.customAlert = .ticketAlreadyRegistered() + } + await store.send(.customAlert(.presented(.cancelTapped))) { + $0.registerNotice = nil + $0.customAlert = nil + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.registerNotice = .alreadyRegistered + $0.customAlert = .ticketAlreadyRegistered() + } + await store.send(.customAlert(.presented(.confirmTapped))) { + $0.registerNotice = nil + $0.customAlert = nil + } + await store.receive(\.delegate.dismiss) + } + + @Test + func 등록_오류는_Android와_같은_안내로_바꾸고_사용_중_이용권만_따로_안내한다() { + #expect(ClassTicketRegisterNotice(error: .claimed) == .alreadyRegistered) + #expect(ClassTicketRegisterNotice(error: .invalidCode) == .invalidCode) + #expect(ClassTicketRegisterNotice(error: .revoked) == .invalidCode) + #expect(ClassTicketRegisterNotice(error: .registrationExpired) == .registrationExpired) + #expect(ClassTicketRegisterNotice(error: .activeTicket) == .alreadyActive) + #expect(ClassTicketRegisterNotice(error: .notFound) == .registerFailed) + #expect(ClassTicketRegisterNotice(error: .invalidResponse) == .registerFailed) + #expect(ClassTicketRegisterNotice(error: .network("x")) == .registerFailed) + } + + @Test + func 확인_단계에서_뒤로가면_코드_입력으로_돌아가고_등록_중에는_무시한다() async { + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + state.phase = .preview + state.preview = ClassTicketPreview( + capacity: 15, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: false + ) + state.isChecking = true + let store = TestStore(initialState: state) { ClassTicketFeature() } + + await store.send(.view(.backTapped)) + await store.send(.binding(.set(\.isChecking, false))) { $0.isChecking = false } + await store.send(.view(.backTapped)) { + $0.phase = .entry + $0.preview = nil + } + } + + @Test + func 코드는_영문_숫자_하이픈만_대문자로_남긴다() { + var state = ClassTicketFeature.State() + state.code = " picke-ab12 cd34!한" + #expect(state.normalizedCode == "PICKE-AB12CD34") + #expect(state.canCheckCode) + state.code = " !한 " + #expect(!state.canCheckCode) + #expect(state.ticketPeriodText == "") + state.code = "1\u{FE0F}\u{20E3}A\u{0332}" + #expect(state.normalizedCode == "1A") + } + + @Test + func 코드_확인과_등록은_정규화한_코드를_보낸다() async { + let codes = LockIsolated<[String]>([]) + let preview = ClassTicketPreview( + capacity: 15, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: false + ) + let ticket = ClassTicket( + id: 3, + capacity: 15, + status: .active, + activatedAt: Date(timeIntervalSince1970: 1_780_000_000), + expiresAt: Date(timeIntervalSince1970: 1_795_000_000), + remainingDays: 162, + readGraceEndsAt: Date(timeIntervalSince1970: 1_797_592_000) + ) + var state = ClassTicketFeature.State() + state.code = " picke-ab12 cd34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub( + preview: .success(preview), + register: .success(ticket), + submittedCodes: codes + ) + } + store.exhaustivity = .off + + await store.send(.view(.checkTapped)) + await store.receive(\.checkResponse) + await store.send(.view(.confirmTapped)) + await store.receive(\.registrationResponse) + + #expect(codes.value == ["PICKE-AB12CD34", "PICKE-AB12CD34"]) + } + + @Test + func 내_코드_재확인_조회가_실패하면_등록_실패_안내를_띄운다() async { + let preview = ClassTicketPreview( + capacity: 30, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: true + ) + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub( + preview: .success(preview), + myTicket: .failure(.network("연결 실패")) + ) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.isLoadingExistingTicket = true + $0.preview = preview + } + await store.receive(\.registeredTicketResponse) { + $0.isLoadingExistingTicket = false + $0.registerNotice = .registerFailed + $0.customAlert = .ticketRegisterFailed() + } + } + + @Test + func 등록이_사용_중_이용권으로_거절되면_사용_중_안내를_띄운다() async { + let preview = ClassTicketPreview( + capacity: 15, + registerBy: Date(timeIntervalSince1970: 1_800_000_000), + usageMonths: 6, + registeredByMe: false + ) + var state = ClassTicketFeature.State() + state.code = "PICKE-AB12-CD34" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.classTicketUseCase = TicketStub( + preview: .success(preview), + register: .failure(.activeTicket) + ) + } + + await store.send(.view(.checkTapped)) { $0.isChecking = true } + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.preview = preview + $0.phase = .preview + } + await store.send(.view(.confirmTapped)) { $0.isChecking = true } + await store.receive(\.registrationResponse) { + $0.isChecking = false + $0.registerNotice = .alreadyActive + $0.customAlert = .ticketAlreadyActive() + } + #expect(store.state.phase == .preview) + } } -private struct TicketStub: ClassTicketInterface { +struct TicketStub: ClassTicketInterface { var preview: Result = .failure(.invalidCode) var register: Result = .failure(.invalidCode) var myTicket: Result = .failure(.notFound) var myTicketFetch: (@Sendable () async throws -> ClassTicket)? + var submittedCodes: LockIsolated<[String]>? - func previewTicket(code _: String) async throws -> ClassTicketPreview { - try preview.get() + func previewTicket(code: String) async throws -> ClassTicketPreview { + submittedCodes?.withValue { $0.append(code) } + return try preview.get() } - func registerTicket(code _: String) async throws -> ClassTicket { - try register.get() + func registerTicket(code: String) async throws -> ClassTicket { + submittedCodes?.withValue { $0.append(code) } + return try register.get() } func fetchMyTicket() async throws -> ClassTicket { diff --git a/Projects/Feature/Class/Tests/Sources/MyClassTests.swift b/Projects/Feature/Class/Tests/Sources/MyClassTests.swift index 652e8d4c..167fcb53 100644 --- a/Projects/Feature/Class/Tests/Sources/MyClassTests.swift +++ b/Projects/Feature/Class/Tests/Sources/MyClassTests.swift @@ -13,6 +13,31 @@ import Testing @MainActor struct MyClassTests { + @Test + func 목록_500_실패는_빈_클래스로_표시하지_않고_재시도한다() async { + let store = TestStore(initialState: MyClassFeature.State()) { MyClassFeature() } withDependencies: { + $0.classRoomUseCase = MockClassRepository(rooms: [], availableRooms: []) + } + + await store.send(.inner(.roomsResponse(0, .failure(.network("HTTP 500"))))) { + $0.viewState = .error + $0.errorMessage = "HTTP 500" + } + #expect(store.state.rooms.isEmpty) + #expect(store.state.viewState == .error) + await store.send(.view(.retryTapped)) + await store.receive(\.async, .fetchRooms) { + $0.fetchGeneration = 1 + $0.isLoading = true + $0.errorMessage = nil + $0.viewState = .loading + } + await store.receive(\.inner, .roomsResponse(1, .success([]))) { + $0.isLoading = false + $0.viewState = .empty + } + } + @Test func 진행_상태로_필터링한다() async { var state = MyClassFeature.State() diff --git a/Projects/Feature/Home/Interface/Sources/HomeInterface.swift b/Projects/Feature/Home/Interface/Sources/HomeInterface.swift index 1ac83c82..4c0d9c1f 100644 --- a/Projects/Feature/Home/Interface/Sources/HomeInterface.swift +++ b/Projects/Feature/Home/Interface/Sources/HomeInterface.swift @@ -10,8 +10,6 @@ import Foundation /// HomeFeature 가 상위 coordinator 로 올려보내는 delegate 계약. public enum HomeDelegate: Equatable, Sendable { case presentPreVote(battleId: Int) - /// "더보기" → 탐색 탭으로 이동. - case moveToExplore /// 알림(종) 아이콘 → 알림받기 화면. case openNotification } diff --git a/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift index 8cbed6c9..ccf51011 100644 --- a/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Feature/Home/Sources/Main/Reducer/HomeFeature.swift @@ -25,6 +25,8 @@ public struct HomeFeature { public enum ViewState: Equatable { case loading case loaded + /// 로딩이 실패한 상태(받아 둔 데이터는 유지) — 데이터가 없으면 스켈레톤이 아니라 빈 상태를 보여준다(Android와 동일). + case failed } public var viewState: ViewState = .loaded @@ -34,28 +36,47 @@ public struct HomeFeature { public var heroIndex: Int = 0 public var hotBattles: [HotBattle] = [] public var bestBattles: [BestBattle] = [] - public var quizzes: [QuizQuestion] = [] - public var votes: [VoteQuestion] = [] + public var bestComments: [HomeBestComment] = [] public var newBattles: [NewBattle] = [] /// 종 아이콘 빨간점 — 미읽음 알림 존재 여부. 화면 진입마다 /unread 서버값으로 갱신(저장 안 함). public var hasUnreadNotification: Bool = false + /// 첫 /unread 결과가 오기 전까지 true — 그동안 종 자리에 shimmer 원을 그린다(Android isAlarmStatusLoading). + public var isUnreadBadgeLoading: Bool = true + + /// 홈 조회 실패 안내 — View 가 토스트로 띄운 뒤 `.toastShown` 으로 비운다. + public var toastMessage: String? /// 출석체크 커스텀 모달 — 오늘 첫 출석에 성공했을 때만 값이 찬다. @Presents public var attendanceModal: AttendanceModalFeature.State? /// 출석 체크는 앱 세션당 1회만 시도한다(하루 1회 제한이라 재진입마다 때릴 이유가 없다). public var hasTriedAttendance: Bool = false - public var currentQuiz: QuizQuestion? { quizzes.first } - public var currentVote: VoteQuestion? { votes.first } + /// 홈 탭 재선택 시 맨 위로 올리라는 요청 토큰(값이 바뀔 때마다 View 가 스크롤). + public var scrollToTopRequest: Int = 0 + + /// Android 상단 바: 첫 로딩 중이거나 미읽음 조회 전이면 종 대신 shimmer 원(`isLoading || isAlarmStatusLoading`). + public var shouldShowBellSkeleton: Bool { + shouldShowSkeleton || isUnreadBadgeLoading + } + + /// Best 배틀은 최대 3개만 노출한다(Android `take(3)`). + public var visibleBestBattles: [BestBattle] { + Array(bestBattles.prefix(3)) + } + + public var hasVisibleHomeContent: Bool { + !heroes.isEmpty || !hotBattles.isEmpty || !bestBattles.isEmpty || + !bestComments.isEmpty || !newBattles.isEmpty + } public var shouldShowSkeleton: Bool { - (viewState == .loading || !hasLoadedHome) && + viewState != .failed && + (viewState == .loading || !hasLoadedHome) && heroes.isEmpty && hotBattles.isEmpty && bestBattles.isEmpty && - quizzes.isEmpty && - votes.isEmpty && + bestComments.isEmpty && newBattles.isEmpty } @@ -75,35 +96,35 @@ public struct HomeFeature { public enum View { case onAppear case pullToRefresh - case seeMoreTapped(Section) - case voteTapped(VoteQuestion) case heroTapped(HeroBattle) case hotBattleTapped(HotBattle) case bestBattleTapped(BestBattle) + case bestCommentTapped(HomeBestComment) case newBattleTapped(NewBattle) case notificationTapped /// 홈 피드 네이티브 광고 클릭 case adNativeClicked + /// 홈 루트에서 홈 탭을 다시 눌렀을 때 — 맨 위로만 이동한다(Android 와 동일, 다시 불러오지 않음). + case homeTabReselected + /// 실패 토스트를 띄운 뒤 메시지를 비운다. + case toastShown } - public enum Section: Equatable { - case hotBattles - case bestBattles - case todayPicke - case newBattles - } - + @CasePathable public enum AsyncAction: Equatable { case fetchHome + /// 당겨서 새로고침 — 스켈레톤 없이 받아 둔 화면 위에서 다시 조회한다. + case refreshHome /// 벨 배지용 미읽음 여부 서버 동기화 (GET /api/v1/notifications/unread). case syncUnreadBadge /// 오늘의 출석 체크 (POST /attendance/check) — 성공 시 주간 현황까지 이어 조회한다. case checkAttendance } + @CasePathable public enum InnerAction: Equatable { case homeResponse(Result) - case unreadBadgeResponse(Bool) + case unreadBadgeResponse(Result) /// 출석 체크 성공 + 주간 현황 조회까지 끝난 결과. case attendanceResponse(weekly: WeeklyAttendance, pointsEarned: Int) } @@ -164,26 +185,16 @@ extension HomeFeature { ? .none : .send(.async(.checkAttendance)) state.hasTriedAttendance = true - guard !state.hasLoadedHome, state.viewState != .loading else { + // 홈 조회는 첫 진입 한 번만(Android ViewModel init) — 실패·로딩 중·로드 완료면 다시 부르지 않고 새로고침으로만 다시 부른다. + // 초기값 `.loaded` + 미로드 = 한 번도 요청하지 않은 상태. + guard !state.hasLoadedHome, state.viewState == .loaded else { return .merge(syncBadge, attendance) } return .merge(syncBadge, attendance, .send(.async(.fetchHome))) case .pullToRefresh: guard state.viewState != .loading else { return .none } - return .send(.async(.fetchHome)) - - case .seeMoreTapped: - analyticsUseCase.track(.uiAction(action: .homeMore, screen: .home)) - return .send(.delegate(.moveToExplore)) - - case let .voteTapped(question): - analyticsUseCase.track(.contentAction(ContentActionData( - action: .voteCardTap, - contentID: "\(question.battleId)", - section: .vote - ))) - return .send(.delegate(.presentPreVote(battleId: question.battleId))) + return .send(.async(.refreshHome)) case let .heroTapped(battle): analyticsUseCase.track(.contentAction(ContentActionData(action: .heroTap, contentID: "\(battle.battleId)"))) @@ -205,6 +216,14 @@ extension HomeFeature { ))) return .send(.delegate(.presentPreVote(battleId: battle.battleId))) + case let .bestCommentTapped(comment): + analyticsUseCase.track(.contentAction(ContentActionData( + action: .battleCardTap, + contentID: "\(comment.battleId)", + section: .bestComment + ))) + return .send(.delegate(.presentPreVote(battleId: comment.battleId))) + case let .newBattleTapped(battle): analyticsUseCase.track(.contentAction(ContentActionData( action: .newBattleTap, @@ -220,6 +239,14 @@ extension HomeFeature { case .adNativeClicked: analyticsUseCase.track(.adClick(AdClickData(placement: .home, format: .native, unit: "ADFIT_NATIVE_2_1"))) return .none + + case .homeTabReselected: + state.scrollToTopRequest += 1 + return .none + + case .toastShown: + state.toastMessage = nil + return .none } } @@ -230,19 +257,18 @@ extension HomeFeature { switch action { case .fetchHome: state.viewState = .loading - return .run { [repository = homeUseCase] send in - let result = await Result { - try await repository.fetchHome() - } - .mapError(AuthError.from) - return await send(.inner(.homeResponse(result))) - } - .cancellable(id: CancelID.fetchHome, cancelInFlight: true) + return requestHome() + + case .refreshHome: + return requestHome() case .syncUnreadBadge: return .run { [useCase = notificationUseCase] send in - guard let hasUnread = try? await useCase.hasUnreadNotifications() else { return } - await send(.inner(.unreadBadgeResponse(hasUnread))) + let result = await Result { + try await useCase.hasUnreadNotifications() + } + .mapError(AuthError.from) + await send(.inner(.unreadBadgeResponse(result))) } .cancellable(id: CancelID.syncUnreadBadge, cancelInFlight: true) @@ -262,15 +288,27 @@ extension HomeFeature { } } + /// 홈 조회 요청 — 첫 조회와 새로고침이 같은 요청을 쓰고, 새 요청이 진행 중인 요청을 취소한다. + private func requestHome() -> Effect { + .run { [repository = homeUseCase] send in + let result = await Result { + try await repository.fetchHome() + } + .mapError(AuthError.from) + return await send(.inner(.homeResponse(result))) + } + .cancellable(id: CancelID.fetchHome, cancelInFlight: true) + } + private func handleInnerAction( state: inout State, action: InnerAction ) -> Effect { switch action { case let .homeResponse(result): - state.viewState = .loaded switch result { case let .success(bundle): + state.viewState = .loaded state.hasLoadedHome = true let home = bundle.replacingEmptySectionsWithMocks state.newNotice = home.newNotice @@ -278,17 +316,21 @@ extension HomeFeature { state.heroIndex = 0 state.hotBattles = home.hotBattles state.bestBattles = home.bestBattles - state.quizzes = home.quizzes - state.votes = home.votes + state.bestComments = home.bestComments state.newBattles = home.newBattles case let .failure(error): + state.viewState = .failed + state.toastMessage = "정보를 불러오지 못했어요" PickeLogger.error("[HomeFeature] fetchHome failed: \(error.localizedDescription)", category: .ui) } return .none - case let .unreadBadgeResponse(hasUnread): - // 서버(/unread) 값을 그대로 반영 — 별도 저장/가드 없이 진입 시점 진실값만 사용. - state.hasUnreadNotification = hasUnread + case let .unreadBadgeResponse(result): + state.isUnreadBadgeLoading = false + // 서버(/unread) 값을 그대로 반영 — 실패하면 기존 배지를 그대로 둔다(Android 와 동일). + if case let .success(hasUnread) = result { + state.hasUnreadNotification = hasUnread + } return .none case let .attendanceResponse(weekly, pointsEarned): @@ -319,7 +361,7 @@ extension HomeFeature { action: HomeDelegate ) -> Effect { switch action { - case .presentPreVote, .moveToExplore, .openNotification: + case .presentPreVote, .openNotification: return .none } } diff --git a/Projects/Feature/Home/Sources/Main/View/Components/BestBattleCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/BestBattleCardView.swift index d19eed80..89f23eda 100644 --- a/Projects/Feature/Home/Sources/Main/View/Components/BestBattleCardView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/BestBattleCardView.swift @@ -5,49 +5,95 @@ // Created by Picke on 5/15/26. // -import SwiftUI - import HomeDomainInterface import PickeDesignKit +import SwiftUI -/// "Best 배틀" 랭킹 카드 (랭크 번호 + 페어 + 카테고리). +/// "Best 배틀" 랭킹 행 (랭크 번호 + 페어 + 제목 + 태그/메타). Android `BestBattleRankItem` 시안. struct BestBattleCardView: View { let battle: BestBattle var body: some View { HStack(alignment: .center, spacing: 16) { - Text("\(battle.rank)") - .pretendardFont(.bold28) - .foregroundStyle(battle.rank == 1 ? .primary500 : .neutral300) - .frame(width: 28, alignment: .center) // QA-41: 순위 숫자(1,2,3) 중앙정렬 - - VStack(alignment: .leading, spacing: 8) { - Text(battle.pair) - .pretendardFont(.semiBold11) - .foregroundStyle(.primary500) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .roundedBackground(.beige600) - - Text(battle.title) - .pretendardFont(.headingSmall) - .foregroundStyle(.neutral900) - .lineLimit(2) - .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(spacing: 8) { - ForEach(battle.tags) { tag in - Text("#\(tag.name)") - .pretendardFont(.medium11) - .foregroundStyle(.neutral300) - } - Spacer(minLength: 8) - MetaLabelView(systemImage: "clock", text: "\(battle.durationMinutes)분") - MetaLabelView(systemImage: "eye", text: "\(battle.viewCount.formatted())") + rankText + infoSection() + } + .padding(.horizontal, 12) + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + } +} + +extension BestBattleCardView { + private var rankText: some View { + Text("\(battle.rank)") + .pretendardFont(.semiBold24) + .foregroundStyle(battle.rank == 1 ? .primary500 : .gray100) + .frame(width: 16, alignment: .center) + } + + @ViewBuilder + private func infoSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + titleSection() + metaSection() + } + } + + @ViewBuilder + private func titleSection() -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(battle.pair) + .pickeBadge(.filled, size: .tag) + Text(battle.title) + .pretendardFont(.headingSmall) + .foregroundStyle(.cardBaseTextTitle) + .lineLimit(1) + .truncationMode(.tail) + } + } + + @ViewBuilder + private func metaSection() -> some View { + HStack(spacing: 0) { + HStack(spacing: 4) { + ForEach(battle.tags) { tag in + Text("#\(tag.name)") + .pretendardFont(.labelSmall) + .foregroundStyle(.cardBaseTextDecription) } } + Spacer(minLength: 8) + HStack(spacing: 6) { + metaLabel( + asset: .clock, + text: "\(battle.durationMinutes)분" + ) + metaLabel( + asset: .eye, + text: "\(battle.viewCount.formatted())" + ) + } } - .padding(.vertical, 16) + } + + /// Android `ic_clock`/`ic_eye` 16pt + 12 Medium(한 줄이라 자연 높이). + @ViewBuilder + private func metaLabel( + asset: ImageAsset, + text: String + ) -> some View { + HStack(spacing: 2) { + Image(asset: asset) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .accessibilityHidden(true) + Text(text) + .pretendardFont(.labelSmall) + } + .foregroundStyle(.cardBaseTextDecription) } } diff --git a/Projects/Feature/Home/Sources/Main/View/Components/HeroCarouselView.swift b/Projects/Feature/Home/Sources/Main/View/Components/HeroCarouselView.swift index 02dd0acd..32f9d74c 100644 --- a/Projects/Feature/Home/Sources/Main/View/Components/HeroCarouselView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HeroCarouselView.swift @@ -5,166 +5,224 @@ // Created by Picke on 5/15/26. // -import SwiftUI - import HomeDomainInterface import PickeDesignKit - import PickeSharedUI +import SwiftUI -/// 최상단 Editor Pick 캐러셀. 좌우 스와이프 + 3초마다 자동 스크롤, 마지막 뒤엔 처음으로 wrap. +/// 최상단 EDITOR PICK 캐러셀 — Android `EditorPickSection` 시안. +/// 위 배지·쪽 번호 줄은 고정이고, 아래 이미지 + 설명만 넘어간다. 3초마다 자동 넘김, 마지막 뒤엔 처음으로. struct HeroCarouselView: View { let heroes: [HeroBattle] @Binding var currentIndex: Int var onTap: (HeroBattle) -> Void = { _ in } private static let autoScrollInterval: TimeInterval = 3 - private static let controlHeight: CGFloat = 51 - private static let thumbnailHeight: CGFloat = 220 - private static let subjectHeight: CGFloat = 88 private let timer = Timer.publish(every: autoScrollInterval, on: .main, in: .common).autoconnect() var body: some View { - TabView(selection: $currentIndex) { - ForEach(Array(heroes.enumerated()), id: \.element.id) { index, hero in - HeroCardView( - hero: hero, - position: index + 1, - total: heroes.count, - thumbnailHeight: Self.thumbnailHeight - ) - .contentShape(Rectangle()) - .onTapGesture { onTap(hero) } - .tag(index) - } + VStack(spacing: 0) { + controlRow() + pager() } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(height: Self.controlHeight + Self.thumbnailHeight + Self - .subjectHeight) // .pen 합: control(53) + thumbnail(167) + subject(121) - .background(.neutral800) + .background(.black) .onReceive(timer) { _ in advance() } } private func advance() { - guard !heroes.isEmpty else { return } - withAnimation(.easeInOut(duration: 0.4)) { + guard heroes.count > 1 else { return } + // Android tween(600, FastOutSlowInEasing) + withAnimation(.timingCurve(0.4, 0, 0.2, 1, duration: 0.6)) { currentIndex = (currentIndex + 1) % heroes.count } } } -/// 캐러셀 내부 단일 hero 카드. -struct HeroCardView: View { - let hero: HeroBattle - let position: Int - let total: Int - let thumbnailHeight: CGFloat - - var body: some View { - VStack(spacing: 0) { - controlRow() - thumbnail() - subject() - } - .background(.neutral800) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } +// MARK: - Sections +extension HeroCarouselView { @ViewBuilder private func controlRow() -> some View { - HStack { - Text(hero.badge) - .pretendardFont(.semiBold11) - .foregroundStyle(.secondary200) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .roundedBackground(.primary500) - - Spacer() - - HStack(spacing: 0) { - Text("\(position)") - .pretendardFont(.labelXSmall) - .foregroundStyle(.secondary50) - Text("/\(total)") - .pretendardFont(.labelXSmall) - .foregroundStyle(.secondary50) - .opacity(0.4) - } - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(.neutral500, in: Capsule()) + HStack(spacing: 0) { + editorPickBadge + Spacer(minLength: 0) + pageCounter } .padding(16) } - @ViewBuilder - private func thumbnail() -> some View { - GeometryReader { proxy in - ZStack { - Rectangle() - .fill(.neutral500.opacity(0.4)) + private var editorPickBadge: some View { + Text("EDITOR PICK") + .pretendardFont(.semiBold11) + .foregroundStyle(.secondary200) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .roundedBackground(.primary500) + } - if let url = hero.thumbnailURL { - PickeRemoteImage(url: url) - .frame( - width: proxy.size.width, - height: proxy.size.height, - alignment: .top - ) - .clipped() - } + /// 현재 쪽은 흰색 Bold, "/전체"는 베이지 60% — 10pt, 배경 textSubtler 80% radius 12. + private var pageCounter: some View { + let current = Text("\(min(currentIndex, heroes.count - 1) + 1)") + .font(.pretendardFontFamily(family: .Bold, fixedSize: 10)) + .foregroundStyle(.white) + let total = Text("/\(heroes.count)") + .font(.pretendardFontFamily(family: .SemiBold, fixedSize: 10)) + .foregroundStyle(.surfaceBeigeDefault.opacity(0.6)) + return Text("\(current)\(total)") + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.textSubtler.opacity(0.8), in: RoundedRectangle(cornerRadius: 12)) + } - Color.black.opacity(0.4) - - HStack(spacing: 24) { - Text(hero.optionA) - .pretendardFont(.headingSmall) - .foregroundStyle(.beige100) - Image(asset: .vs) - .resizable() - .scaledToFit() - .frame(width: 18, height: 32) - Text(hero.optionB) - .pretendardFont(.headingSmall) - .foregroundStyle(.beige100) + /// 높이는 투명 기준 레이아웃(폭 ÷ 1.8 이미지 + 빈 설명)에서 정하고 그 위에 페이지를 겹친다. + @ViewBuilder + private func pager() -> some View { + VStack(spacing: 0) { + Color.clear + .aspectRatio(1.8, contentMode: .fit) + HeroSubjectView( + title: " ", + summary: " ", + tagsText: " ", + viewCountText: " " + ) + .hidden() + } + .overlay { + TabView(selection: $currentIndex) { + ForEach(Array(heroes.enumerated()), id: \.element.id) { index, hero in + HeroPageView(hero: hero) + .contentShape(Rectangle()) + .onTapGesture { onTap(hero) } + .tag(index) } - .opacity(0.85) } + .tabViewStyle(.page(indexDisplayMode: .never)) } - .frame(height: thumbnailHeight) - .clipped() } +} - @ViewBuilder - private func subject() -> some View { - HStack(alignment: .bottom) { - VStack(alignment: .leading, spacing: 0) { - Text(hero.title) - .pretendardFont(.headingMedium) - .foregroundStyle(.beige100) - .padding(.bottom, 4) - Text(hero.summary) - .pretendardFont(.labelSmall) - .foregroundStyle(.neutral200) - .lineLimit(2) - - HStack(spacing: 4) { - ForEach(hero.tags) { tag in - Text("#\(tag.name)") - .pretendardFont(.medium11) - .foregroundStyle(.neutral200) +// MARK: - Page + +/// 캐러셀 한 쪽 — 이미지(폭 ÷ 1.8) + 설명. +private struct HeroPageView: View { + let hero: HeroBattle + + var body: some View { + VStack(spacing: 0) { + thumbnail + HeroSubjectView( + title: hero.title, + summary: hero.summary, + tagsText: hero.tags.map { "#\($0.name)" }.joined(separator: " "), + viewCountText: "\(hero.viewCount.formatted())" + ) + } + .frame(maxHeight: .infinity, alignment: .top) + } + + /// 이미지 위 검정 30% 덮개 + 가운데 선택지 · VS. + private var thumbnail: some View { + Color.clear + .aspectRatio(1.8, contentMode: .fit) + .overlay { + if let url = hero.thumbnailURL { + PickeRemoteImage(url: url) { + SkeletonView( + .rect, + base: .gray600, + highlight: .gray400 + ) } } - .padding(.top, 6) } + .clipped() + .overlay { + Color.black.opacity(0.3) + } + .overlay { + versusRow() + } + } - Spacer() + @ViewBuilder + private func versusRow() -> some View { + HStack(spacing: 16) { + optionText(hero.optionA) + Image(asset: .vs) + .renderingMode(.template) + .resizable() + .frame(width: 18, height: 32) + .foregroundStyle(.white) + optionText(hero.optionB) + } + } + + /// Android 그림자 offset 2px·blur 4px → 3x 기준 pt. + private func optionText(_ text: String) -> some View { + Text(text) + .pretendardFont(.headingMedium) + .foregroundStyle(.white) + .shadow( + color: .black, + radius: 4.0 / 3, + x: 2.0 / 3, + y: 2.0 / 3 + ) + } +} + +/// 이미지 아래 설명 — 제목 1줄, 요약 항상 2줄 높이, 태그 + 조회수. +private struct HeroSubjectView: View { + let title: String + let summary: String + let tagsText: String + let viewCountText: String - MetaLabelView(systemImage: "eye", text: "\(hero.viewCount.formatted())") + /// Android `minLines = 2` — 자연 높이 + 줄높이(12 × 1.4). `reservesSpace` 는 줄 간격을 빼고 잡아 쓰지 않는다. + private static let summaryMinHeight: CGFloat = { + let natural = UIFont.pretendardFontFamily(family: .Medium, size: 12).lineHeight + return natural + 12 * 1.4 + }() + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .pretendardFont(.headingMedium) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) + Text(summary) + .pretendardFont(.labelSmall, lineSpacingMultiple: 1.4) + .foregroundStyle(.gray400) + .lineLimit(2) + .truncationMode(.tail) + .frame(minHeight: Self.summaryMinHeight, alignment: .topLeading) + .padding(.top, 4) + metaRow() + .padding(.top, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + + @ViewBuilder + private func metaRow() -> some View { + HStack(spacing: 0) { + // 기준 레이아웃 높이를 넘지 않게 태그는 한 줄로 둔다. + Text(tagsText) + .pretendardFont(.labelSmall) + .foregroundStyle(.textMuted) + .lineLimit(1) + Spacer(minLength: 0) + MetaLabelView( + asset: .eye, + text: viewCountText, + iconSize: 16, + spacing: 4, + iconColor: .gray400, + textColor: .textMuted + ) } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 20) } } diff --git a/Projects/Feature/Home/Sources/Main/View/Components/HomeBestCommentRow.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeBestCommentRow.swift new file mode 100644 index 00000000..14d58fc4 --- /dev/null +++ b/Projects/Feature/Home/Sources/Main/View/Components/HomeBestCommentRow.swift @@ -0,0 +1,189 @@ +// +// HomeBestCommentRow.swift +// Home +// + +import HomeDomainInterface +import PickeDesignKit +import SwiftUI +import UIKit + +/// Best 댓글 행 — Android `HomeBestCommentItem` 시안. +/// 본문 첫 줄 앞에 배지 폭만큼 투명 자리를 두고, 선택지 배지는 첫 줄 기준선에 overlay 로 겹친다. +/// Android 처럼 배지가 글꼴보다 위·아래로 튀어나온 만큼만 본문 위·아래를 늘리고 줄 간격(13 × 1.4)은 그대로 둔다. +struct HomeBestCommentRow: View { + let comment: HomeBestComment + @Environment(\.displayScale) private var displayScale + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + battleTitleText + contentSection() + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityText) + .accessibilityAddTraits(.isButton) + } +} + +extension HomeBestCommentRow { + /// 본문 글꼴(Android bodyXsRegular) — 배지 위치 계산과 한 곳에서 맞춘다. + private static let contentFont: CustomSizeFont = .regular13 + private static let contentLineHeightMultiple: CGFloat = 1.4 + + private var accessibilityText: String { + let commentText = [comment.optionTitle, comment.content] + .filter { !$0.isEmpty } + .joined(separator: " ") + return [comment.battleTitle, commentText] + .filter { !$0.isEmpty } + .joined(separator: ", ") + } + + private var battleTitleText: some View { + Text(comment.battleTitle) + .pretendardFont(.semiBold13) + .foregroundStyle(.textMuted) + .lineLimit(1) + .truncationMode(.tail) + } + + @ViewBuilder + private func contentSection() -> some View { + let badge = OptionBadgeImage.make( + title: comment.optionTitle, + scale: displayScale + ) + HStack(alignment: .top, spacing: 0) { + Image(asset: .replyCorner) + .resizable() + .frame(width: 16, height: 16) + .foregroundStyle(.primary500) + contentText(badge: badge) + .pretendardFont(Self.contentFont, lineSpacingMultiple: Self.contentLineHeightMultiple) + .foregroundStyle(.textSubtler) + .lineLimit(2) + .truncationMode(.tail) + // 배지를 Text 안에 넣으면 SwiftUI 가 줄 위쪽을 배지 높이만큼 잡는다 → 본문엔 투명 자리만 두고 배지는 첫 줄 기준선에 겹친다. + .overlay(alignment: Alignment(horizontal: .leading, vertical: .firstTextBaseline)) { + if let badge { + Image(uiImage: badge) + .renderingMode(.original) + .alignmentGuide(.firstTextBaseline) { [descent = badgeDescent(badge)] dimension in + dimension.height - descent + } + } + } + .padding(.top, badge.map(badgeTopOverflow) ?? 0) + .padding(.bottom, badge.map(badgeBottomOverflow) ?? 0) + .padding(.horizontal, 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func contentText(badge: UIImage?) -> Text { + guard let badge else { + return Text(verbatim: comment.content) + } + let placeholderText = Text(Image(uiImage: OptionBadgeImage.placeholder(for: badge))) + return Text("\(placeholderText)\(Text(verbatim: comment.content))") + } + + private static var contentUIFont: UIFont { + UIFont.pretendardFontFamily( + family: contentFont.fontFamily, + size: contentFont.size + ) + } + + /// 배지 세로 중앙을 본문 글꼴 (ascent − descent) / 2 에 맞춘다(Android PlaceholderVerticalAlign.TextCenter). + /// 반환값은 기준선 아래로 내려가는 배지 높이. + private func badgeDescent(_ badge: UIImage) -> CGFloat { + let font = Self.contentUIFont + let textCenter = (font.ascender + font.descender) / 2 + return badge.size.height / 2 - textCenter + } + + /// 배지 위가 글꼴 ascent 보다 높은 만큼 — Android 는 첫 줄 위쪽이 이만큼 늘어난다(Trim.Both 는 글꼴 수치 기준). + private func badgeTopOverflow(_ badge: UIImage) -> CGFloat { + let badgeTop = badge.size.height - badgeDescent(badge) + return max(0, badgeTop - Self.contentUIFont.ascender) + } + + /// 배지 아래가 글꼴 descent 보다 낮은 만큼 — Android 는 첫 줄 글꼴 수치를 마지막 줄에도 써서 본문 아래가 이만큼 늘어난다. + private func badgeBottomOverflow(_ badge: UIImage) -> CGFloat { + max(0, badgeDescent(badge) + Self.contentUIFont.descender) + } +} + +/// 선택지 배지 이미지 — 배경 radius 2, Medium 12(Android bodyXxsMedium, 한 줄이라 자연 높이), 가로 4·세로 2, 뒤쪽 투명 간격 4 포함. +@MainActor +private enum OptionBadgeImage { + private static var cache: [String: UIImage] = [:] + /// 선택지 제목은 서버에서 계속 바뀌므로 캐시가 쌓이지 않게 상한을 둔다. + private static let cacheLimit = 64 + + private static let horizontalPadding: CGFloat = 4 + private static let verticalPadding: CGFloat = 2 + private static let trailingSpacing: CGFloat = 4 + private static let cornerRadius: CGFloat = 2 + private static let badgeFont: CustomSizeFont = .labelSmall + + static func make( + title: String, + scale: CGFloat + ) -> UIImage? { + guard !title.isEmpty else { return nil } + let key = "\(title)|\(scale)" + if let cached = cache[key] { + return cached + } + + let font = UIFont.pretendardFontFamily( + family: badgeFont.fontFamily, + size: badgeFont.size + ) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor(Color.badgeFilledText), + ] + let textWidth = ceil((title as NSString).size(withAttributes: attributes).width) + let badgeWidth = textWidth + horizontalPadding * 2 + let badgeHeight = font.lineHeight + verticalPadding * 2 + + let format = UIGraphicsImageRendererFormat() + format.scale = scale + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: badgeWidth + trailingSpacing, height: badgeHeight), + format: format + ) + let image = renderer.image { _ in + let badgeRect = CGRect(x: 0, y: 0, width: badgeWidth, height: badgeHeight) + UIColor(Color.badgeFilledBackground).setFill() + UIBezierPath(roundedRect: badgeRect, cornerRadius: cornerRadius).fill() + let textOrigin = CGPoint( + x: horizontalPadding, + y: verticalPadding + ) + (title as NSString).draw(at: textOrigin, withAttributes: attributes) + } + if cache.count >= cacheLimit { + cache.removeAll() + } + cache[key] = image + return image + } + + /// 본문 안에서 배지 폭만 차지하는 투명 자리 — 높이 1이라 줄 위쪽 높이를 바꾸지 않는다. + static func placeholder(for badge: UIImage) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = badge.scale + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: badge.size.width, height: 1), + format: format + ) + return renderer.image { _ in } + } +} diff --git a/Projects/Feature/Home/Sources/Main/View/Components/HomeHeaderView.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeHeaderView.swift index 0c75be25..761d3bec 100644 --- a/Projects/Feature/Home/Sources/Main/View/Components/HomeHeaderView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HomeHeaderView.swift @@ -5,49 +5,70 @@ // Created by Picke on 5/15/26. // -import SwiftUI - import PickeDesignKit +import SwiftUI -/// 홈 화면 최상단 GNB 위 헤더 (PicKé 로고 + 알림 아이콘). +/// 홈 상단 바 — Android `PickeTopAppBar`(로고 + 알림 액션) 시안. 높이 48, 아래 구분선 없음. struct HomeHeaderView: View { let hasUnread: Bool + /// 첫 로딩 중이거나 미읽음 조회 전이면 종 대신 shimmer 원을 그린다. + let isLoading: Bool let onNotificationTapped: () -> Void var body: some View { - HStack { - Image(asset: .appLogo) - .resizable() - .scaledToFit() - .frame(width: 62, height: 39) - - Spacer() - - Button(action: onNotificationTapped) { - bellIcon() - } + HStack(spacing: 0) { + logo + Spacer(minLength: 0) + notificationAction() } - .padding(.horizontal, 24) - .padding(.vertical, 8) - .frame(height: 56) - .background(.beige50) - .bottomDivider(.beige600) + .padding(.leading, 16) + .padding(.trailing, 4) + .frame(height: 48) + .background(.bgBeige) } } -private extension HomeHeaderView { +extension HomeHeaderView { + private var logo: some View { + Image(asset: .appLogo) + .resizable() + .frame(width: 63, height: 40) + .accessibilityLabel("Picke 로고") + } + + /// Android `IconButton`(48 터치 영역) 안 20pt 아이콘, 로딩 중엔 같은 자리 24pt 원. @ViewBuilder - func bellIcon() -> some View { - if hasUnread { - Image(asset: .bell) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) + private func notificationAction() -> some View { + if isLoading { + SkeletonView( + .circle, + base: .surfaceBeigeStrong, + highlight: .surfaceBeigeDefault + ) + .frame(width: 24, height: 24) + .frame(width: 48, height: 48) } else { - Image(systemName: "bell") - .font(.system(size: 20, weight: .regular)) - .foregroundStyle(.neutral900) - .frame(width: 24, height: 24) + Button(action: onNotificationTapped) { + bellIcon + .frame(width: 48, height: 48) + .contentShape(Rectangle()) + } + .accessibilityLabel("알림") } } + + /// Material3 소형 배지(6pt)는 아이콘 오른쪽 끝에서 6 안쪽·위쪽 0에 놓이고, Android 가 offset(4, −4)을 더 준다. + private var bellIcon: some View { + Image(asset: .alarm) + .resizable() + .frame(width: 20, height: 20) + .overlay(alignment: .topLeading) { + if hasUnread { + Circle() + .fill(.primary500) + .frame(width: 6, height: 6) + .offset(x: 20 - 6 + 4, y: -4) + } + } + } } diff --git a/Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift index 620ba433..469c01b3 100644 --- a/Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HomeSectionHeader.swift @@ -5,50 +5,28 @@ // Created by Picke on 5/15/26. // -import SwiftUI - import PickeDesignKit +import SwiftUI -/// "지금 뜨는 배틀 / 더 보기" 같은 섹션 헤더 공통 컴포넌트. +/// "지금 뜨는 배틀" 같은 섹션 헤더 공통 컴포넌트. struct HomeSectionHeader: View { let title: String - let onSeeMoreTapped: () -> Void var body: some View { - HStack(spacing: 12) { - Text(attributedTitle) - .pretendardFont(.bold18) - .kerning(-0.45) - Spacer(minLength: 0) - Button(action: onSeeMoreTapped) { - Text("더 보기") - .pretendardFont(.medium13) - .foregroundStyle(.neutral300) - } - } - .padding(.horizontal, 16) + Text(attributedTitle) + .pretendardFont(family: .SemiBold, size: 18) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + // Android headingMd 는 한 줄이라 줄높이 1.3이 잘려(Trim.Both) 글꼴 자연 높이 + 세로 12씩이다. + .padding(.vertical, 12) } - /// 강조 규칙: - /// - 라틴 단어 (Best · Pické 등) 가 있으면 그 단어만 primary500, 나머지 한글은 neutral900 - /// - 라틴 단어가 없으면 한글 `배틀` 만 primary500, 나머지 한글은 neutral900 + /// Figma 홈 헤더: 섹션 주제어(배틀/댓글)만 primary500으로 강조한다. private var attributedTitle: AttributedString { var attr = AttributedString(title) - attr.foregroundColor = .neutral900 - - let latinPattern = /[A-Za-zÀ-ÿ]+/ - let latinMatches = Array(title.matches(of: latinPattern)) - - if latinMatches.isEmpty { - if let range = attr.range(of: "배틀") { - attr[range].foregroundColor = .primary500 - } - } else { - for match in latinMatches { - if let range = attr.range(of: String(match.0)) { - attr[range].foregroundColor = .primary500 - } - } + attr.foregroundColor = .textDefault + if let range = attr.range(of: "배틀") ?? attr.range(of: "댓글") { + attr[range].foregroundColor = .primary500 } return attr } diff --git a/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift b/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift index 9cfd5436..2e65fc73 100644 --- a/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HomeSkeletonView.swift @@ -5,266 +5,174 @@ // Created by Picke on 5/16/26. // -import SwiftUI - import PickeDesignKit +import SwiftUI +/// 첫 로딩 스켈레톤 — Android `HomeSkeleton` 구성. +/// 줄은 radius 4, 색은 surfaceBeigeStrong → surfaceBeigeDefault, 섹션 간격 24 / 40 / 16 / 16. struct HomeSkeletonView: View { var body: some View { - VStack(spacing: 32) { - HomeHeroSkeletonView() - HomeHotBattlesSkeletonView() - HomeBestBattlesSkeletonView() - HomeTodayPickeSkeletonView() - HomeNewBattlesSkeletonView() + VStack(alignment: .leading, spacing: 0) { + editorPickSkeleton() + hotBattlesSkeleton() + .padding(.top, 24) + bestBattlesSkeleton() + .padding(.top, 40) + bestCommentsSkeleton() + .padding(.top, 16) + newBattlesSkeleton() + .padding(.top, 16) } - .padding(.bottom, 24) + .frame(maxWidth: .infinity, alignment: .leading) .allowsHitTesting(false) } } -private struct HomeHeroSkeletonView: View { - var body: some View { - VStack(spacing: 0) { - HStack { - SkeletonBlock(width: 82, height: 18, cornerRadius: .radiusDefault, color: .primary500.opacity(0.45)) - Spacer() - SkeletonBlock(width: 36, height: 18, cornerRadius: 9, color: .neutral500.opacity(0.5)) - } - .padding(16) +// MARK: - Sections - ZStack { - Rectangle() - .fill(.neutral700.opacity(0.8)) - HStack(spacing: 24) { - SkeletonBlock(width: 58, height: 14, color: .beige100.opacity(0.24)) - SkeletonBlock(width: 32, height: 32, cornerRadius: 16, color: .beige100.opacity(0.18)) - SkeletonBlock(width: 58, height: 14, color: .beige100.opacity(0.24)) - } - } - .frame(height: 167) - - VStack(alignment: .leading, spacing: 8) { - SkeletonBlock(width: 210, height: 18, color: .beige100.opacity(0.22)) - SkeletonBlock(width: 260, height: 12, color: .neutral200.opacity(0.2)) - HStack { - SkeletonBlock(width: 92, height: 12, color: .neutral200.opacity(0.18)) - Spacer() - SkeletonBlock(width: 48, height: 12, color: .neutral200.opacity(0.18)) - } +extension HomeSkeletonView { + @ViewBuilder + private func editorPickSkeleton() -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + line(width: 70, height: 16) + Spacer(minLength: 0) + line(width: 32, height: 16) } - .padding(20) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + block(.rect) + .aspectRatio(1.8, contentMode: .fit) + editorPickSubjectSkeleton() + .padding(16) } - .frame(height: 341) - .background(.neutral800) } -} -private struct HomeHotBattlesSkeletonView: View { - var body: some View { - VStack(alignment: .leading, spacing: 12) { - SkeletonSectionHeader(width: 132) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { - ForEach(0 ..< 2, id: \.self) { _ in - VStack(alignment: .leading, spacing: 12) { - SkeletonBlock(width: 196, height: 124) - VStack(alignment: .leading, spacing: 8) { - SkeletonBlock(width: 42, height: 20, color: .primary50) - SkeletonBlock(width: 150, height: 16) - SkeletonBlock(width: 104, height: 12) - } - } - .padding(12) - .frame(width: 220, alignment: .leading) - .pickeCard(.beige50, border: .beige600) - } - } - .padding(.horizontal, 16) + @ViewBuilder + private func editorPickSubjectSkeleton() -> some View { + VStack(alignment: .leading, spacing: 0) { + line(width: 160, height: 20) + line(width: 240, height: 16) + .padding(.top, 4) + line(width: 200, height: 16) + .padding(.top, 4) + HStack(spacing: 0) { + line(width: 100, height: 14) + Spacer(minLength: 0) + line(width: 40, height: 14) } + .padding(.top, 8) } } -} -private struct HomeBestBattlesSkeletonView: View { - var body: some View { - VStack(alignment: .leading, spacing: 12) { - SkeletonSectionHeader(width: 88) - VStack(spacing: 0) { - ForEach(0 ..< 3, id: \.self) { index in - HStack(alignment: .top, spacing: 16) { - SkeletonBlock(width: 18, height: 28, color: index == 0 ? .primary500.opacity(0.35) : .neutral100) - VStack(alignment: .leading, spacing: 10) { - SkeletonBlock(width: 76, height: 18, color: .primary50) - SkeletonBlock(width: 214, height: 16) - HStack(spacing: 8) { - SkeletonBlock(width: 40, height: 12) - SkeletonBlock(width: 44, height: 12) - Spacer() - SkeletonBlock(width: 96, height: 12) - } - } - } - .padding(.vertical, 16) - - if index < 2 { - Divider() - .background(.beige600) - } - } + /// Android Row 는 두 번째 카드를 남은 폭(358 − 232 = 126)으로 줄인다 → 첫 카드만 220 고정. + @ViewBuilder + private func hotBattlesSkeleton() -> some View { + VStack(alignment: .leading, spacing: 0) { + line(width: 120, height: 20) + HStack(alignment: .top, spacing: 12) { + trendingCardSkeleton() + .frame(width: 220) + trendingCardSkeleton() + .frame(maxWidth: 220) } - .padding(.horizontal, 16) + .padding(.top, 12) } + .padding(.horizontal, 16) } -} -private struct HomeTodayPickeSkeletonView: View { - var body: some View { - VStack(alignment: .leading, spacing: 16) { - SkeletonSectionHeader(width: 104) - VStack(spacing: 16) { - todayQuizCard() - todayVoteCard() - } - .padding(.horizontal, 16) + @ViewBuilder + private func trendingCardSkeleton() -> some View { + VStack(alignment: .leading, spacing: 12) { + block(.round(cornerRadius: 4)) + .aspectRatio(1.2, contentMode: .fit) + line(width: 160, height: 16) } } @ViewBuilder - private func todayQuizCard() -> some View { - VStack(alignment: .leading, spacing: 20) { - HStack { - SkeletonBlock(width: 42, height: 20, color: .primary50) - Spacer() - SkeletonBlock(width: 76, height: 12) - } - VStack(spacing: 8) { - SkeletonBlock(width: 220, height: 16) - SkeletonBlock(width: 260, height: 12) - SkeletonBlock(width: 180, height: 12) - } - .frame(maxWidth: .infinity) - - HStack(spacing: 8) { - SkeletonBlock(height: 74, color: .beige50) - SkeletonBlock(height: 74, color: .beige50) + private func bestBattlesSkeleton() -> some View { + VStack(alignment: .leading, spacing: 0) { + line(width: 80, height: 20) + VStack(alignment: .leading, spacing: 16) { + ForEach(0 ..< 3, id: \.self) { _ in + bestBattleRowSkeleton() + } } + .padding(.vertical, 16) } - .padding(.vertical, 20) .padding(.horizontal, 16) - .pickeCard(.beige400, border: .beige700) } @ViewBuilder - private func todayVoteCard() -> some View { - VStack(alignment: .leading, spacing: 20) { - HStack { - SkeletonBlock(width: 42, height: 20, color: .primary50) - Spacer() - SkeletonBlock(width: 76, height: 12) - } - VStack(spacing: 8) { - HStack(spacing: 8) { - SkeletonBlock(width: 78, height: 16) - SkeletonBlock(width: 44, height: 24, color: .beige50) - SkeletonBlock(width: 24, height: 16) - } - SkeletonBlock(width: 230, height: 12) - } - .frame(maxWidth: .infinity) - - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), spacing: 8) { - ForEach(0 ..< 4, id: \.self) { _ in - SkeletonBlock(height: 44, color: .beige50) - } + private func bestBattleRowSkeleton() -> some View { + HStack(alignment: .top, spacing: 16) { + block(.round(cornerRadius: 4)) + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 8) { + line(width: 200, height: 18) + line(width: 100, height: 14) } } - .padding(.vertical, 20) - .padding(.horizontal, 16) - .pickeCard(.beige50, border: .beige700) } -} -private struct HomeNewBattlesSkeletonView: View { - var body: some View { - VStack(alignment: .leading, spacing: 16) { - SkeletonSectionHeader(width: 104) - VStack(spacing: 12) { + @ViewBuilder + private func bestCommentsSkeleton() -> some View { + VStack(alignment: .leading, spacing: 0) { + line(width: 80, height: 20) + VStack(alignment: .leading, spacing: 16) { ForEach(0 ..< 3, id: \.self) { _ in - VStack(alignment: .leading, spacing: 12) { - HStack { - SkeletonBlock(width: 42, height: 20, color: .primary50) - Spacer() - SkeletonBlock(width: 92, height: 12) - } - SkeletonBlock(width: 240, height: 16) - SkeletonBlock(width: 300, height: 12) - HStack(spacing: 8) { - SkeletonBattleOption() - SkeletonBlock(width: 32, height: 32, cornerRadius: 16, color: .secondary100) - SkeletonBattleOption() - } - } - .padding(12) - .pickeCard(.beige50, border: .beige600) + bestCommentRowSkeleton() } } - .padding(.horizontal, 16) + .padding(.vertical, 16) } + .padding(.horizontal, 16) } -} -private struct SkeletonBattleOption: View { - var body: some View { - HStack(spacing: 8) { - SkeletonBlock(width: 28, height: 28, cornerRadius: 14, color: .beige500) - VStack(alignment: .leading, spacing: 4) { - SkeletonBlock(width: 46, height: 12) - SkeletonBlock(width: 28, height: 10) - } - Spacer(minLength: 0) + @ViewBuilder + private func bestCommentRowSkeleton() -> some View { + VStack(alignment: .leading, spacing: 8) { + line(width: 160, height: 14) + line(width: 280, height: 14) + line(width: 200, height: 14) } - .padding(8) - .frame(maxWidth: .infinity) - .pickeCard(.beige300, border: .beige600) } -} - -private struct SkeletonSectionHeader: View { - let width: CGFloat - var body: some View { - HStack { - SkeletonBlock(width: width, height: 22, color: .neutral100) - Spacer() - SkeletonBlock(width: 40, height: 14) + @ViewBuilder + private func newBattlesSkeleton() -> some View { + VStack(alignment: .leading, spacing: 0) { + line(width: 100, height: 20) + VStack(spacing: 12) { + ForEach(0 ..< 2, id: \.self) { _ in + block(.round(cornerRadius: .radiusDefault)) + .frame(height: 120) + } + } + .padding(.vertical, 12) } .padding(.horizontal, 16) } } -private struct SkeletonBlock: View { - var width: CGFloat? - var height: CGFloat - var cornerRadius: CGFloat - var color: Color - - init( - width: CGFloat? = nil, - height: CGFloat, - cornerRadius: CGFloat = 2, - color: Color = .beige600.opacity(0.55) - ) { - self.width = width - self.height = height - self.cornerRadius = cornerRadius - self.color = color +// MARK: - Parts + +extension HomeSkeletonView { + /// Android `PickeSkeletonLine` — 폭은 최대값(좁은 자리에서는 줄어듦), radius 4. + private func line( + width: CGFloat, + height: CGFloat + ) -> some View { + block(.round(cornerRadius: 4)) + .frame(maxWidth: width) + .frame(height: height) } - var body: some View { - SkeletonView(.round(cornerRadius: cornerRadius), base: color) - .frame(width: width, height: height) - .frame(maxWidth: width == nil ? .infinity : nil) + /// Android `Modifier.shimmer()` 기본 색. + private func block(_ shape: SkeletonShape) -> some View { + SkeletonView( + shape, + base: .surfaceBeigeStrong, + highlight: .surfaceBeigeDefault + ) } } diff --git a/Projects/Feature/Home/Sources/Main/View/Components/HotBattleCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/HotBattleCardView.swift index a147d504..862255b0 100644 --- a/Projects/Feature/Home/Sources/Main/View/Components/HotBattleCardView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/HotBattleCardView.swift @@ -5,56 +5,88 @@ // Created by Picke on 5/15/26. // -import SwiftUI - import HomeDomainInterface import PickeDesignKit - import PickeSharedUI +import SwiftUI -/// "지금 뜨는 배틀" 가로 스크롤 카드 (220 wide). +/// "지금 뜨는 배틀" 가로 스크롤 카드 — Android `TrendingBattleCard` 시안(폭 220). struct HotBattleCardView: View { let battle: HotBattle var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 0) { thumbnail - VStack(alignment: .leading, spacing: 6) { - if let tag = battle.tags.first { - Text("#\(tag.name)") - .pretendardFont(.medium11) - .foregroundStyle(.primary500) - } - Text(battle.title) - .pretendardFont(.headingSmall) - .foregroundStyle(.neutral900) - // 제목 1줄이어도 2줄 높이를 확보해 카드 높이를 통일. 2줄 초과는 말줄임(...) 처리. - .lineLimit(2, reservesSpace: true) - .truncationMode(.tail) - HStack(spacing: 8) { - MetaLabelView(systemImage: "clock", text: "\(battle.durationMinutes)분") - MetaLabelView(systemImage: "eye", text: "\(battle.viewCount.formatted())") - } - } + tagBadge + .padding(.top, 12) + titleText + .padding(.top, 8) + metaRow() + .padding(.top, 12) } .padding(12) .frame(width: 220, alignment: .leading) - .pickeCard(.beige50, border: .beige600) + .pickeCard( + .surfaceBeigeDefault, + border: .borderBeigeDefault, + insideBorder: true + ) } +} - @ViewBuilder +extension HotBattleCardView { + /// 1.2 비율 썸네일 — 바깥 radius 4 + 안쪽 테두리 4(borderBeigeSelected), 이미지가 없으면 배경색만. private var thumbnail: some View { - // QA-43: Figma node 3888-3736 기준 — height 140, border 4pt(.borderBeigeSelected), radius 2. - Group { - if let url = battle.thumbnailURL { - PickeRemoteImage(url: url) - } else { - Rectangle() - .fill(.beige500) + Color.bgBeige + .aspectRatio(1.2, contentMode: .fit) + .overlay { + if let url = battle.thumbnailURL { + PickeRemoteImage(url: url) { + SkeletonView( + .rect, + base: .surfaceBeigeStrong, + highlight: .surfaceBeigeDefault + ) + } + } } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .roundedBorder( + .borderBeigeSelected, + lineWidth: 4, + radius: 4, + insideBorder: true + ) + } + + private var tagBadge: some View { + Text(battle.tagLabel) + .pretendardFont(.labelXSmall) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .roundedBackground(.borderBeigeDefault) + } + + private var titleText: some View { + Text(battle.title) + .pretendardFont(.headingSmall) + .foregroundStyle(.textDefault) + .lineLimit(1) + .truncationMode(.tail) + } + + @ViewBuilder + private func metaRow() -> some View { + HStack(spacing: 8) { + MetaLabelView( + asset: .clock, + text: "\(battle.durationMinutes)분" + ) + MetaLabelView( + asset: .eye, + text: "\(battle.viewCount.formatted())" + ) } - .frame(width: 196, height: 140) - .clipShape(RoundedRectangle(cornerRadius: .radiusDefault)) - .roundedBorder(.borderBeigeSelected, lineWidth: 4) } } diff --git a/Projects/Feature/Home/Sources/Main/View/Components/MetaLabelView.swift b/Projects/Feature/Home/Sources/Main/View/Components/MetaLabelView.swift deleted file mode 100644 index 403c60ed..00000000 --- a/Projects/Feature/Home/Sources/Main/View/Components/MetaLabelView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// MetaLabelView.swift -// Home -// -// Created by Picke on 5/15/26. -// - -import SwiftUI - -import PickeDesignKit - -/// 시계/눈 같은 메타 정보를 SF Symbol + 12pt 텍스트로 표시. -struct MetaLabelView: View { - let systemImage: String - let text: String - - var body: some View { - HStack(spacing: 2) { - Image(systemName: systemImage) - .resizable().scaledToFit() - .frame(width: 12, height: 12) - .foregroundStyle(.neutral300) - Text(text) - .pretendardFont(.medium11) - .foregroundStyle(.neutral300) - } - } -} - -/// 작은 알약형 태그 뱃지 (예: "퀴즈", "투표"). -struct TagBadgeView: View { - let text: String - - var body: some View { - Text(text) - .pretendardFont(.semiBold11) - .foregroundStyle(.primary500) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .roundedBackground(.primary50) - } -} diff --git a/Projects/Feature/Home/Sources/Main/View/Components/NewBattleCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/NewBattleCardView.swift index 9f4bb87b..b66e374b 100644 --- a/Projects/Feature/Home/Sources/Main/View/Components/NewBattleCardView.swift +++ b/Projects/Feature/Home/Sources/Main/View/Components/NewBattleCardView.swift @@ -5,23 +5,31 @@ // Created by Picke on 5/15/26. // -import SwiftUI - import HomeDomainInterface import PickeDesignKit - import PickeSharedUI +import SwiftUI -/// "새로운 배틀" 리스트 카드 (.pen `Card/BattleListCard` 의 thumbnail 제외 구성). +/// "새로운 배틀" 리스트 카드 — Android `NewBattleCard` 시안. struct NewBattleCardView: View { let battle: NewBattle var body: some View { - VStack(alignment: .leading, spacing: 8) { - content + VStack(alignment: .leading, spacing: 0) { + metaRow() + titleText + .padding(.top, 12) + summaryText + .padding(.top, 6) + versusRow() + .padding(.top, 8) } .padding(12) - .pickeCard(.beige50, border: .beige600) + .pickeCard( + .surfaceBeigeDefault, + border: .borderBeigeDefault, + insideBorder: true + ) } } @@ -29,123 +37,64 @@ struct NewBattleCardView: View { extension NewBattleCardView { @ViewBuilder - private var content: some View { - VStack(alignment: .leading, spacing: 16) { - container - versusRow + private func metaRow() -> some View { + HStack(spacing: 0) { + tagBadge + Spacer(minLength: 0) + HStack(spacing: 8) { + MetaLabelView( + asset: .clock, + text: "\(battle.durationMinutes)분", + spacing: 4 + ) + MetaLabelView( + asset: .eye, + text: "\(battle.viewCount.formatted())", + spacing: 4 + ) + } } } - @ViewBuilder - private var container: some View { - VStack(alignment: .leading, spacing: 12) { - metaRow - titleBlock - } + private var tagBadge: some View { + Text(battle.tagLabel) + .pretendardFont(.labelSmall) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .roundedBackground(.borderBeigeDefault) } - @ViewBuilder - private var metaRow: some View { - HStack(spacing: 10) { - if let tag = battle.tags.first { - Text("#\(tag.name)") - .pickeBadge(.filled, size: .tag) - } - Spacer() - MetaLabelView(systemImage: "clock", text: "\(battle.durationMinutes)분") - MetaLabelView(systemImage: "eye", text: "\(battle.viewCount.formatted())") - } + private var titleText: some View { + Text(battle.title) + .pretendardFont(.headingSmall, lineSpacingMultiple: 1.4) + .foregroundStyle(.textDefault) + .frame(maxWidth: .infinity, alignment: .leading) } - @ViewBuilder - private var titleBlock: some View { - VStack(alignment: .leading, spacing: 4) { - Text(battle.title) - .pretendardFont(.headingSmall) - .foregroundStyle(.neutral500) - .kerning(-0.35) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - Text(battle.summary) - .pretendardFont(.labelSmall) - .foregroundStyle(.neutral200) - .lineSpacing(12 * 0.4) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - } + private var summaryText: some View { + Text(battle.summary) + .pretendardFont(.labelSmall, lineSpacingMultiple: 1.4) + .foregroundStyle(.gray400) + .lineLimit(2) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder - private var versusRow: some View { - HStack(spacing: 8) { - admissionButton( - label: battle.optionATitle, - sub: battle.philosopherA, + private func versusRow() -> some View { + HStack(spacing: 2) { + BattleOpinionBox( + opinion: battle.optionATitle, + name: battle.philosopherA, imageURL: battle.philosopherAImageURL ) - vsBadge - admissionButton( - label: battle.optionBTitle, - sub: battle.philosopherB, + BattleVersusBadge() + BattleOpinionBox( + opinion: battle.optionBTitle, + name: battle.philosopherB, imageURL: battle.philosopherBImageURL ) } } } - -// MARK: - Sub-components - -extension NewBattleCardView { - @ViewBuilder - private func admissionButton( - label: String, - sub: String, - imageURL: URL? - ) -> some View { - HStack(spacing: 4) { - avatar(for: sub, imageURL: imageURL) - VStack(alignment: .leading, spacing: 2) { - Text(label) - .pretendardFont(.headingSmall) - .foregroundStyle(.neutral600) - .kerning(-0.35) - Text(sub) - .pretendardFont(.labelSmall) - .foregroundStyle(.neutral300) - } - Spacer(minLength: 0) - } - .padding(8) - .frame(maxWidth: .infinity) - .pickeCard(.beige300, border: .beige600) - } - - @ViewBuilder - private func avatar( - for _: String, - imageURL: URL? - ) -> some View { - // .pen `Avatar/Philosopher` 매핑: 베이지 40×40 원형 배경 + 가운데 16×28 일러스트 - ZStack { - Circle() - .fill(.beige600) - .frame(width: 40, height: 40) - if let imageURL { - PickeRemoteImage(url: imageURL, shape: .round(cornerRadius: 20)) - .content(.fit) - .frame(width: 20, height: 38) - } - } - .frame(width: 40, height: 40) - } - - @ViewBuilder - private var vsBadge: some View { - // Figma(4037:4233 VS) 매핑: 24×24 secondary200(#F3E3C7) 원형 + Pretendard Bold 8 neutral900(#131212) 글자. - Text("VS") - .pretendardFont(.bold8) - .foregroundStyle(.neutral900) - .frame(width: 24, height: 24) - .background(.secondary200, in: Circle()) - } -} diff --git a/Projects/Feature/Home/Sources/Main/View/Components/QuizCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/QuizCardView.swift deleted file mode 100644 index 9b702477..00000000 --- a/Projects/Feature/Home/Sources/Main/View/Components/QuizCardView.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// QuizCardView.swift -// Home -// -// Created by Picke on 5/15/26. -// - -import SwiftUI - -import HomeDomainInterface -import PickeDesignKit - -/// "오늘의 Pické — 퀴즈" 카드. 옵션 탭 → 정답 비교 → O/X 결과 라벨 노출. -struct QuizCardView: View { - let question: QuizQuestion - - @State private var selectedOption: Choice? - - enum Choice: Equatable { - case a - case b - } - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - header() - titleBlock() - options() - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - .pickeCard(.beige400, border: .beige700) - } - - @ViewBuilder - private func header() -> some View { - HStack { - TagBadgeView(text: "퀴즈") - Spacer() - Text("\(question.participantCount.formatted())명 참여") - .pretendardFont(.medium11) - .foregroundStyle(.neutral300) - } - } - - @ViewBuilder - private func titleBlock() -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(question.title) - .pretendardFont(.semiBold15) - .foregroundStyle(.neutral900) - .kerning(-0.375) - Text(question.summary) - .pretendardFont(.labelSmall) - .foregroundStyle(.neutral200) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } - } - - @ViewBuilder - private func options() -> some View { - HStack(spacing: 8) { - optionButton(.a, label: question.itemA, desc: question.itemADesc, isCorrect: question.isCorrectA) - optionButton(.b, label: question.itemB, desc: question.itemBDesc, isCorrect: question.isCorrectB) - } - } - - @ViewBuilder - private func optionButton( - _ choice: Choice, - label: String, - desc: String, - isCorrect: Bool - ) -> some View { - let isSelected = selectedOption == choice - let hasAnswered = selectedOption != nil - Button { - selectedOption = isSelected ? nil : choice - } label: { - VStack(spacing: 2) { - resultBadge(isSelected: isSelected, isCorrect: isCorrect) - Text(label) - .pretendardFont(.semiBold13) - .foregroundStyle(.neutral900) - Text(desc) - .pretendardFont(.medium10) - .foregroundStyle(.neutral300) - } - .frame(maxWidth: .infinity) - .padding(12) - .pickeCard(.beige50, border: .beige500) - .opacity(hasAnswered && !isSelected ? 0.5 : 1) - } - .buttonStyle(.plain) - } - - @ViewBuilder - private func resultBadge( - isSelected: Bool, - isCorrect: Bool - ) -> some View { - if isSelected { - Text(isCorrect ? "O 정답" : "X 오답") - .pretendardFont(.labelXSmall) - .foregroundStyle(isCorrect ? .secondary500 : .primary500) - } else { - Color.clear.frame(height: 14) - } - } -} diff --git a/Projects/Feature/Home/Sources/Main/View/Components/VoteCardView.swift b/Projects/Feature/Home/Sources/Main/View/Components/VoteCardView.swift deleted file mode 100644 index be08a136..00000000 --- a/Projects/Feature/Home/Sources/Main/View/Components/VoteCardView.swift +++ /dev/null @@ -1,198 +0,0 @@ -// -// VoteCardView.swift -// Home -// -// Created by Picke on 5/15/26. -// - -import SwiftUI - -import HomeDomainInterface -import PickeDesignKit - -/// "오늘의 Pické — 투표" 카드. 옵션 탭 시 result 모드로 전환되어 -/// 빈칸에 선택지 텍스트가 채워지고 옵션 박스 아래에 percentage bar 들이 표시된다. -struct VoteCardView: View { - let question: VoteQuestion - - @State private var selectedIndex: Int? - @State private var animatedFill: Bool = false - - private var isResultMode: Bool { selectedIndex != nil } - - private var selectedLabel: String? { - guard let idx = selectedIndex else { return nil } - return question.options[safe: idx]?.title - } - - private let columns = [ - GridItem(.flexible(), spacing: 8), - GridItem(.flexible(), spacing: 8), - ] - - /// API 가 결과 비율을 내려주기 전까지 사용하는 임시 mock 비율. - private static let mockPercentages: [Int] = [45, 25, 20, 10] - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - header() - heading() - grid() - if isResultMode { - resultBars() - } - } - .padding(.vertical, 20) - .padding(.horizontal, 16) - .pickeCard(.beige50, border: .beige600) - } - - @ViewBuilder - private func header() -> some View { - HStack { - Text("투표") - .pretendardFont(.headingSmall) - .foregroundStyle(.primary500) - .frame(width: 35, height: 21) - .roundedBackground(.beige600) - - Spacer() - - Text("\(question.participantCount.formatted())명 참여") - .pretendardFont(.medium11) - .foregroundStyle(.neutral300) - } - } - - @ViewBuilder - private func heading() -> some View { - VStack(spacing: 6) { - HStack(spacing: 4) { - Text(question.titlePrefix) - .pretendardFont(.semiBold15) - .foregroundStyle(.neutral500) - - answerSlot() - - Text(question.titleSuffix) - .pretendardFont(.semiBold15) - .foregroundStyle(.neutral500) - } - - Text(question.summary) - .pretendardFont(.labelSmall) - .foregroundStyle(.neutral300) - } - .frame(maxWidth: .infinity) - } - - /// 빈칸: 선택 전엔 빈 placeholder, 선택 후엔 선택된 옵션 텍스트 표시. - /// 선택된 라벨 길이에 맞춰 가변 폭 — 글자가 잘리지 않도록 horizontal padding 만 두고 - /// 최소 폭을 placeholder(52pt) 와 동일하게 유지한다. - @ViewBuilder - private func answerSlot() -> some View { - if let label = selectedLabel { - Text(label) - .pretendardFont(.semiBold15) - .foregroundStyle(.primary500) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .padding(.horizontal, 8) - .frame(minWidth: 52, minHeight: 24) - .pickeCard(.beige200, border: .primary500) - } else { - RoundedRectangle(cornerRadius: .radiusDefault) - .fill(.beige200) - .frame(width: 52, height: 24) - .roundedBorder(.beige600) - } - } - - @ViewBuilder - private func grid() -> some View { - LazyVGrid(columns: columns, spacing: 7) { - ForEach(Array(question.options.enumerated()), id: \.offset) { idx, option in - optionButton(index: idx, label: option.title) - } - } - } - - @ViewBuilder - private func optionButton( - index: Int, - label: String - ) -> some View { - let isSelected = selectedIndex == index - - Button { - withAnimation(.easeInOut(duration: 0.2)) { - selectedIndex = isSelected ? nil : index - } - } label: { - Text(label) - .pretendardFont(.semiBold13) - .foregroundStyle(.neutral900) - .frame(maxWidth: .infinity, minHeight: 44) - .roundedBackground(.beige400) - .overlay( - RoundedRectangle(cornerRadius: .radiusDefault) - .stroke(isSelected ? .primary500 : .beige600, lineWidth: isSelected ? 1.5 : 1) - ) - } - .buttonStyle(.plain) - } - - /// Result mode 옵션 박스 아래 별도 영역 — 4 row (옵션명 + bar + percentage). - /// .pen `Radar Wrap` 디자인을 2-column 으로 재배치. - @ViewBuilder - private func resultBars() -> some View { - LazyVGrid(columns: columns, spacing: 8) { - ForEach(Array(question.options.enumerated()), id: \.offset) { idx, option in - resultBarRow( - label: option.title, - percentage: Self.mockPercentages[safe: idx] ?? 0 - ) - } - } - .padding(.top, 4) - .onAppear { - animatedFill = false - withAnimation(.easeOut(duration: 0.6)) { - animatedFill = true - } - } - .onDisappear { - animatedFill = false - } - } - - @ViewBuilder - private func resultBarRow( - label: String, - percentage: Int - ) -> some View { - HStack(spacing: 6) { - Text(label) - .pretendardFont(.medium10) - .foregroundStyle(.neutral400) - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 1) - .fill(.secondary100) - .frame(width: 48, height: 4) - RoundedRectangle(cornerRadius: 1) - .fill(.secondary500) - .frame(width: animatedFill ? 48 * CGFloat(percentage) / 100 : 0, height: 4) - } - Spacer(minLength: 0) - Text("\(percentage)%") - .pretendardFont(.bold11) - .foregroundStyle(.neutral500) - } - } -} - -private extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil - } -} diff --git a/Projects/Feature/Home/Sources/Main/View/HomeView.swift b/Projects/Feature/Home/Sources/Main/View/HomeView.swift index 86ac10ef..2f90fd41 100644 --- a/Projects/Feature/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Feature/Home/Sources/Main/View/HomeView.swift @@ -5,19 +5,19 @@ // Created by Picke on 5/15/26. // -import SwiftUI - +import ComposableArchitecture +import FeatureSharedUI import HomeDomainInterface import PickeDesignKit import PickeSharedUI - -import ComposableArchitecture -import FeatureSharedUI +import SwiftUI @ViewAction(for: HomeFeature.self) public struct HomeView: View { @Bindable public var store: StoreOf + private static let topAnchorID = "homeTop" + public init(store: StoreOf) { self.store = store } @@ -26,42 +26,11 @@ public struct HomeView: View { VStack(spacing: 0) { HomeHeaderView( hasUnread: store.hasUnreadNotification, + isLoading: store.shouldShowBellSkeleton, onNotificationTapped: { send(.notificationTapped) } ) // sticky — 스크롤 영향 없음 - ScrollView(showsIndicators: false) { - if store.shouldShowSkeleton { - HomeSkeletonView() - } else { - VStack(spacing: 32) { - if !store.heroes.isEmpty { - HeroCarouselView( - heroes: store.heroes, - currentIndex: $store.heroIndex, - onTap: { send(.heroTapped($0)) } - ) - } - - if !store.hotBattles.isEmpty { - hotBattlesSection() - adSection() - } - if !store.bestBattles.isEmpty { - bestBattlesSection() - if store.hotBattles.isEmpty { - adSection() - } - } - if !store.quizzes.isEmpty || !store.votes.isEmpty { - todayPickeSection() - } - if !store.newBattles.isEmpty { - newBattlesSection() - } - } - .padding(.bottom, 24) - } - } + scrollContent } .screenBackground() .pickeModal( @@ -73,15 +42,105 @@ public struct HomeView: View { AttendanceModalView(store: modalStore) } .onAppear { send(.onAppear) } + .onChange(of: store.toastMessage, initial: true) { _, message in + guard let message else { return } + ToastManager.shared.showError(message) + send(.toastShown) + } .navigationBarHidden(true) .scrollIndicators(.hidden) - .scrollBounceBehavior(.basedOnSize) + // 세로는 항상 튕겨야 내용이 짧은 빈 화면에서도 당겨서 새로고침이 된다. + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) } } // MARK: - Sections extension HomeView { + /// 홈 탭 재선택 시 맨 위 앵커로 스크롤하고, 당기면 새로고침한다(Android PullToRefreshBox). + private var scrollContent: some View { + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + Color.clear + .frame(height: 0) + .id(Self.topAnchorID) + contentSection() + } + } + .refreshable { + await send(.pullToRefresh).finish() + } + .onChange(of: store.scrollToTopRequest) { + withAnimation { + proxy.scrollTo(Self.topAnchorID, anchor: .top) + } + } + } + } + + @ViewBuilder + private func contentSection() -> some View { + if store.shouldShowSkeleton { + HomeSkeletonView() + } else if !store.hasVisibleHomeContent { + emptyHomeSection() + } else { + loadedSection() + } + } + + /// 섹션 간격은 Android `HomeScreen` 의 Spacer 값(24/40/24/32/32/40)을 그대로 따른다. + @ViewBuilder + private func loadedSection() -> some View { + VStack(spacing: 0) { + if !store.heroes.isEmpty { + HeroCarouselView( + heroes: store.heroes, + currentIndex: $store.heroIndex, + onTap: { send(.heroTapped($0)) } + ) + .padding(.bottom, 24) + } + + hotBattlesSection() + .padding(.bottom, 40) + adSection() + .padding(.bottom, 24) + if !store.bestBattles.isEmpty { + bestBattlesSection() + .padding(.bottom, 32) + } + if !store.bestComments.isEmpty { + bestCommentsSection() + .padding(.bottom, 32) + } + if !store.newBattles.isEmpty { + newBattlesSection() + } + } + .padding(.bottom, 40) + } + + /// Android 빈 상태 — 로고(borderBeigeDefault 색) + 문구를 스크롤 영역 가운데에 둔다. + @ViewBuilder + private func emptyHomeSection() -> some View { + VStack(spacing: 16) { + Image(asset: .appLogo) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 160, height: 120) + .foregroundStyle(.borderBeigeDefault) + .accessibilityLabel("빈 화면 로고") + Text("아직 준비된 배틀이 없어요!") + .pretendardFont(.bodyMedium) + .foregroundStyle(.beige800) + } + .frame(maxWidth: .infinity) + .containerRelativeFrame(.vertical) + } + /// 배틀 섹션 아래 네이티브 광고 — AdFit 은 한 화면에 같은 단위 1개만 허용해 호출부에서 하나만 그린다. @ViewBuilder private func adSection() -> some View { @@ -94,57 +153,64 @@ extension HomeView { @ViewBuilder private func hotBattlesSection() -> some View { - VStack(alignment: .leading, spacing: 12) { - HomeSectionHeader(title: "지금 뜨는 배틀") { - send(.seeMoreTapped(.hotBattles)) - } - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { - ForEach(store.hotBattles) { battle in - HotBattleCardView(battle: battle) - .contentShape(Rectangle()) - .onTapGesture { send(.hotBattleTapped(battle)) } + VStack(alignment: .leading, spacing: 0) { + HomeSectionHeader(title: "지금 뜨는 배틀") + if store.hotBattles.isEmpty { + Text("지금 뜨는 배틀이 아직 없어요") + .pretendardFont(.bodyMedium) + .foregroundStyle(.textMuted) + .multilineTextAlignment(.center) + .frame(width: 220, height: 274) + .background(.surfaceBeigeSubtle) + .roundedBorder( + .borderBeigeDefault, + lineWidth: 1, + insideBorder: true + ) + .padding(.horizontal, 16) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(store.hotBattles) { battle in + HotBattleCardView(battle: battle) + .contentShape(Rectangle()) + .onTapGesture { send(.hotBattleTapped(battle)) } + } } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) } } } @ViewBuilder - private func bestBattlesSection() -> some View { - VStack(alignment: .leading, spacing: 12) { - HomeSectionHeader(title: "Best 배틀") { - send(.seeMoreTapped(.bestBattles)) - } + private func bestCommentsSection() -> some View { + VStack(alignment: .leading, spacing: 0) { + HomeSectionHeader(title: "Best 댓글") VStack(spacing: 0) { - ForEach(Array(store.bestBattles.enumerated()), id: \.element.id) { index, battle in - BestBattleCardView(battle: battle) + ForEach(Array(store.bestComments.enumerated()), id: \.offset) { index, comment in + HomeBestCommentRow(comment: comment) .contentShape(Rectangle()) - .onTapGesture { send(.bestBattleTapped(battle)) } - if index < store.bestBattles.count - 1 { - PickeDivider(.beige600) + .onTapGesture { send(.bestCommentTapped(comment)) } + if index < store.bestComments.count - 1 { + PickeDivider(.cardBaseBorderDefault) + .padding(.horizontal, 16) } } } - .padding(.horizontal, 16) } } @ViewBuilder - private func todayPickeSection() -> some View { - VStack(alignment: .leading, spacing: 16) { - HomeSectionHeader(title: "오늘의 Pické") { - send(.seeMoreTapped(.todayPicke)) - } - VStack(spacing: 16) { - if let quiz = store.currentQuiz { - QuizCardView(question: quiz) - } - if let vote = store.currentVote { - VoteCardView(question: vote) + private func bestBattlesSection() -> some View { + VStack(alignment: .leading, spacing: 0) { + HomeSectionHeader(title: "Best 배틀") + VStack(spacing: 0) { + ForEach(store.visibleBestBattles) { battle in + BestBattleCardView(battle: battle) .contentShape(Rectangle()) - .onTapGesture {} + .onTapGesture { send(.bestBattleTapped(battle)) } + PickeDivider(.cardBaseBorderDefault) } } .padding(.horizontal, 16) @@ -153,10 +219,8 @@ extension HomeView { @ViewBuilder private func newBattlesSection() -> some View { - VStack(alignment: .leading, spacing: 16) { - HomeSectionHeader(title: "새로운 배틀") { - send(.seeMoreTapped(.newBattles)) - } + VStack(alignment: .leading, spacing: 0) { + HomeSectionHeader(title: "새로운 배틀") VStack(spacing: 12) { ForEach(store.newBattles) { battle in NewBattleCardView(battle: battle) @@ -165,6 +229,7 @@ extension HomeView { } } .padding(.horizontal, 16) + .padding(.bottom, 12) } } } diff --git a/Projects/Feature/Home/Tests/Sources/HomeTests.swift b/Projects/Feature/Home/Tests/Sources/HomeTests.swift index 9e79b66b..ef92ed88 100644 --- a/Projects/Feature/Home/Tests/Sources/HomeTests.swift +++ b/Projects/Feature/Home/Tests/Sources/HomeTests.swift @@ -7,11 +7,11 @@ import ComposableArchitecture import Foundation +@testable import Home import HomeDomainInterface +import PickeAnalyticsInterface import Testing -@testable import Home - struct HomeTests { @Test func initialEmptyHomeShowsSkeletonBeforeOnAppear() { @@ -22,19 +22,241 @@ struct HomeTests { @Test @MainActor - func failedHomeResponseAllowsRetryOnReentry() async { + func failedHomeResponseShowsEmptyStateAndToastWithoutRefetchOnReentry() async { var state = HomeFeature.State() state.viewState = .loading + state.hasTriedAttendance = true + let fetchCount = LockIsolated(0) let store = TestStore(initialState: state) { HomeFeature() + } withDependencies: { + $0.analyticsUseCase.track = { _ in } + $0.homeUseCase = StubHomeUseCase { + fetchCount.withValue { $0 += 1 } + return .badgeStaleMock + } } await store.send(.inner(.homeResponse(.failure(.networkError("offline"))))) { - $0.viewState = .loaded + $0.viewState = .failed + $0.toastMessage = "정보를 불러오지 못했어요" } #expect(store.state.hasLoadedHome == false) - #expect(store.state.shouldShowSkeleton) + #expect(store.state.shouldShowSkeleton == false) + #expect(store.state.hasVisibleHomeContent == false) + + // Android 처럼 재진입해도 다시 부르지 않는다(새로고침으로만 다시 부름). + store.exhaustivity = .off + await store.send(.view(.onAppear)) + await store.finish() + await store.skipReceivedActions() + #expect(fetchCount.value == 0) + #expect(store.state.viewState == .failed) + } + + @Test + @MainActor + func firstOnAppearFetchesHomeOnce() async { + var state = HomeFeature.State() + state.hasTriedAttendance = true + let fetchCount = LockIsolated(0) + let store = TestStore(initialState: state) { + HomeFeature() + } withDependencies: { + $0.analyticsUseCase.track = { _ in } + $0.homeUseCase = StubHomeUseCase { + fetchCount.withValue { $0 += 1 } + return .badgeStaleMock + } + } + store.exhaustivity = .off + + await store.send(.view(.onAppear)) + await store.receive(\.async.fetchHome) { + $0.viewState = .loading + } + await store.receive(\.inner.homeResponse) { + $0.viewState = .loaded + $0.hasLoadedHome = true + } + await store.finish() + + // 로드가 끝난 뒤 재진입해도 다시 부르지 않는다. + await store.send(.view(.onAppear)) + await store.finish() + await store.skipReceivedActions() + #expect(fetchCount.value == 1) + } + + @Test + @MainActor + func unreadBadgeSuccessStopsBellLoadingAndShowsBadge() async { + var state = HomeFeature.State() + state.hasLoadedHome = true + let store = TestStore(initialState: state) { + HomeFeature() + } + + await store.send(.inner(.unreadBadgeResponse(.success(true)))) { + $0.isUnreadBadgeLoading = false + $0.hasUnreadNotification = true + } + #expect(store.state.shouldShowBellSkeleton == false) + } + + @Test + @MainActor + func pullToRefreshReloadsWithoutSkeleton() async { + let comment = HomeBestComment( + battleId: 21, + battleTitle: "인간은 본래 선한가", + optionTitle: "선하다", + content: "선하게 태어나요" + ) + var state = HomeFeature.State() + state.viewState = .loaded + state.hasLoadedHome = true + state.bestComments = [comment] + let store = TestStore(initialState: state) { + HomeFeature() + } withDependencies: { + $0.homeUseCase = StubHomeUseCase { .badgeStaleMock } + } + + await store.send(.view(.pullToRefresh)) + await store.receive(\.async.refreshHome) + #expect(store.state.shouldShowSkeleton == false) + await store.receive(\.inner.homeResponse) { + $0.newNotice = true + $0.bestComments = [] + } + } + + @Test + @MainActor + func pullToRefreshFailureKeepsContentAndShowsToast() async { + let comment = HomeBestComment( + battleId: 21, + battleTitle: "인간은 본래 선한가", + optionTitle: "선하다", + content: "선하게 태어나요" + ) + var state = HomeFeature.State() + state.viewState = .loaded + state.hasLoadedHome = true + state.bestComments = [comment] + let store = TestStore(initialState: state) { + HomeFeature() + } withDependencies: { + $0.homeUseCase = StubHomeUseCase { throw URLError(.notConnectedToInternet) } + } + + await store.send(.view(.pullToRefresh)) + await store.receive(\.async.refreshHome) + await store.receive(\.inner.homeResponse) { + $0.viewState = .failed + $0.toastMessage = "정보를 불러오지 못했어요" + } + #expect(store.state.bestComments == [comment]) + + await store.send(.view(.toastShown)) { + $0.toastMessage = nil + } + } + + @Test + @MainActor + func pullToRefreshIsIgnoredWhileFirstLoading() async { + var state = HomeFeature.State() + state.viewState = .loading + let store = TestStore(initialState: state) { + HomeFeature() + } + + await store.send(.view(.pullToRefresh)) + } + + @Test + @MainActor + func unreadBadgeFailureStopsBellLoadingAndKeepsBadge() async { + var state = HomeFeature.State() + state.hasUnreadNotification = true + state.hasLoadedHome = true + let store = TestStore(initialState: state) { + HomeFeature() + } + #expect(store.state.shouldShowBellSkeleton) + + await store.send(.inner(.unreadBadgeResponse(.failure(.networkError("offline"))))) { + $0.isUnreadBadgeLoading = false + } + #expect(store.state.hasUnreadNotification) + #expect(store.state.shouldShowBellSkeleton == false) + } + + @Test + func visibleBestBattlesShowsOnlyFirstThree() { + var state = HomeFeature.State() + state.bestBattles = (1 ... 4).map { + BestBattle( + battleId: $0, + rank: $0, + philosopherA: "A", + philosopherB: "B", + title: "배틀 \($0)", + audioDuration: 60, + viewCount: 10 + ) + } + + #expect(state.visibleBestBattles.map(\.battleId) == [1, 2, 3]) + } + + @Test + @MainActor + func bestCommentTapTracksBestCommentSectionAndPresentsPreVote() async { + let comment = HomeBestComment( + battleId: 21, + battleTitle: "인간은 본래 선한가", + optionTitle: "선하다", + content: "선하게 태어나요" + ) + let tracked = LockIsolated<[ContentActionData]>([]) + let store = TestStore(initialState: HomeFeature.State()) { + HomeFeature() + } withDependencies: { + $0.analyticsUseCase.track = { event in + guard case let .contentAction(data) = event else { return } + tracked.withValue { $0.append(data) } + } + } + + await store.send(.view(.bestCommentTapped(comment))) + await store.receive(\.delegate) + + #expect(tracked.value.count == 1) + #expect(tracked.value.first?.section == .bestComment) + #expect(tracked.value.first?.contentID == "21") + } + + /// Android 처럼 홈 탭 재선택은 어떤 상태에서든 맨 위로만 올리고 다시 부르지 않는다. + @Test + @MainActor + func homeTabReselectedOnlyScrollsToTop() async { + let viewStates: [HomeFeature.State.ViewState] = [.loaded, .loading, .failed] + for viewState in viewStates { + var state = HomeFeature.State() + state.viewState = viewState + state.hasLoadedHome = viewState == .loaded + let store = TestStore(initialState: state) { + HomeFeature() + } + + await store.send(.view(.homeTabReselected)) { + $0.scrollToTopRequest = 1 + } + } } @Test @@ -53,6 +275,51 @@ struct HomeTests { #expect(store.state.hasUnreadNotification == false) #expect(store.state.shouldShowSkeleton == false) } + + @Test + @MainActor + func homeResponseStoresBestCommentsWithoutMockFallback() async { + let comment = HomeBestComment( + battleId: 21, + battleTitle: "인간은 본래 선한가", + optionTitle: "선하다", + content: "선하게 태어나요" + ) + let store = TestStore(initialState: HomeFeature.State()) { + HomeFeature() + } + + await store.send(.inner(.homeResponse(.success(.commentsOnly(comment))))) { + $0.hasLoadedHome = true + $0.bestComments = [comment] + } + + #expect(store.state.hotBattles.isEmpty) + #expect(store.state.hasVisibleHomeContent) + #expect(store.state.shouldShowSkeleton == false) + } + + @Test + func loadedEmptyHomeHasNoVisibleContent() { + var state = HomeFeature.State() + state.hasLoadedHome = true + + #expect(state.shouldShowSkeleton == false) + #expect(state.hasVisibleHomeContent == false) + } +} + +/// 홈 조회 결과를 테스트마다 정하는 가짜 UseCase. +private struct StubHomeUseCase: HomeInterface { + let fetch: @Sendable () async throws -> HomeBundle + + init(_ fetch: @escaping @Sendable () async throws -> HomeBundle) { + self.fetch = fetch + } + + func fetchHome() async throws -> HomeBundle { + try await fetch() + } } private extension HomeBundle { @@ -65,4 +332,17 @@ private extension HomeBundle { votes: [], newBattles: [] ) + + static func commentsOnly(_ comment: HomeBestComment) -> HomeBundle { + HomeBundle( + newNotice: false, + heroes: [], + hotBattles: [], + bestBattles: [], + bestComments: [comment], + quizzes: [], + votes: [], + newBattles: [] + ) + } } diff --git a/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift b/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift index 2aefadcf..f63af6c8 100644 --- a/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift +++ b/Projects/Service/API/Sources/Class/Battle/ClassBattleAPI.swift @@ -1,6 +1,7 @@ public enum ClassBattleAPI { case detail(classID: Int) case preVote(classID: Int) + case midVote(classID: Int) case ttsComplete(classID: Int) case postVote(classID: Int) case myVotes(classID: Int) @@ -12,6 +13,7 @@ public enum ClassBattleAPI { switch self { case let .detail(id): return "classes/\(id)/battle" case let .preVote(id): return "classes/\(id)/battle/votes/pre" + case let .midVote(id): return "classes/\(id)/battle/votes/mid" 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" diff --git a/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift b/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift index 62b20f2d..88e336f9 100644 --- a/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift +++ b/Projects/Service/API/Sources/Class/Result/ClassResultAPI.swift @@ -3,6 +3,7 @@ public enum ClassResultAPI { case myReport(classID: Int) case insight(classID: Int) case retryInsight(classID: Int) + case myActivity(classID: Int) public var description: String { switch self { @@ -10,6 +11,7 @@ public enum ClassResultAPI { 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" + case let .myActivity(id): return "classes/\(id)/result/my-activity" } } } diff --git a/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift b/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift index ef3c7455..4b1b5209 100644 --- a/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift +++ b/Projects/Service/APIEndpoint/Sources/Class/Battle/ClassBattleService.swift @@ -6,6 +6,7 @@ import PickeNetwork public enum ClassBattleService { case detail(classID: Int) case preVote(classID: Int, optionID: Int) + case midVote(classID: Int, checkpointID: Int, optionID: Int) case ttsComplete(classID: Int) case postVote(classID: Int, optionID: Int) case myVotes(classID: Int) @@ -22,6 +23,7 @@ extension ClassBattleService: PickeDataRequest { switch self { case let .detail(classID): return ClassBattleAPI.detail(classID: classID).description case let .preVote(classID, _): return ClassBattleAPI.preVote(classID: classID).description + case let .midVote(classID, _, _): return ClassBattleAPI.midVote(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 @@ -35,7 +37,7 @@ extension ClassBattleService: PickeDataRequest { public var method: HTTPMethod { switch self { case .detail, .myVotes, .voteStats, .perspectives, .myPerspectives: return .get - case .preVote, .ttsComplete, .postVote, .publishPerspective: return .post + case .preVote, .midVote, .ttsComplete, .postVote, .publishPerspective: return .post } } @@ -43,6 +45,8 @@ extension ClassBattleService: PickeDataRequest { switch self { case let .preVote(_, optionID), let .postVote(_, optionID): return VoteBody(optionId: optionID) + case let .midVote(_, checkpointID, optionID): + return MidVoteBody(checkpointId: checkpointID, optionId: optionID) case let .publishPerspective(_, content): return PerspectiveBody(content: content) case let .perspectives(_, cursor, size, optionID, sort): diff --git a/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift index d46892c6..2c8bccf7 100644 --- a/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift +++ b/Projects/Service/APIEndpoint/Sources/Class/Battle/Request/ClassBattleRequests.swift @@ -4,6 +4,11 @@ struct VoteBody: Encodable, Sendable { let optionId: Int } +struct MidVoteBody: Encodable, Sendable { + let checkpointId: Int + let optionId: Int +} + struct PerspectiveBody: Encodable, Sendable { let content: String } diff --git a/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift b/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift index d282d4a7..1dc16841 100644 --- a/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift +++ b/Projects/Service/APIEndpoint/Sources/Class/Result/ClassResultService.swift @@ -7,6 +7,7 @@ public enum ClassResultService { case myReport(Int) case insight(Int) case retryInsight(Int) + case myActivity(Int, cursor: String?) } extension ClassResultService: PickeDataRequest { @@ -20,17 +21,21 @@ extension ClassResultService: PickeDataRequest { 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 + case let .myActivity(id, _): return ClassResultAPI.myActivity(classID: id).description } } public var method: HTTPMethod { switch self { - case .result, .myReport, .insight: return .get + case .result, .myReport, .insight, .myActivity: return .get case .retryInsight: return .post } } public var parameters: (any Encodable & Sendable)? { - nil + switch self { + case let .myActivity(_, cursor): return MyActivityQuery(cursor: cursor) + case .result, .myReport, .insight, .retryInsight: return nil + } } } diff --git a/Projects/Service/APIEndpoint/Sources/Class/Result/Request/ClassResultRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Result/Request/ClassResultRequests.swift new file mode 100644 index 00000000..0b4c0391 --- /dev/null +++ b/Projects/Service/APIEndpoint/Sources/Class/Result/Request/ClassResultRequests.swift @@ -0,0 +1,5 @@ +import Foundation + +struct MyActivityQuery: Encodable, Sendable { + let cursor: String? +} diff --git a/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift b/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift index 095b46d8..7e2d5446 100644 --- a/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift +++ b/Projects/Service/APIEndpoint/Sources/Class/Room/Request/ClassroomRequests.swift @@ -10,6 +10,7 @@ struct CreateClassBody: Encodable, Sendable { let commentRequired: Bool let ownerParticipates: Bool let ownerDisplayName: String? + let requestKey: String? init(creation: ClassCreation) { name = creation.name @@ -21,6 +22,7 @@ struct CreateClassBody: Encodable, Sendable { commentRequired = creation.requiresComment ownerParticipates = creation.ownerParticipates ownerDisplayName = creation.ownerDisplayName + requestKey = creation.requestKey } } diff --git a/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift index 322c7a34..4fe40b4d 100644 --- a/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift +++ b/Projects/Service/PickeAnalytics/Interface/Event/AnalyticsButton.swift @@ -9,7 +9,6 @@ import Foundation public enum AnalyticsButton: String, Sendable { // 홈 - case homeMore = "home_more" case homeNotification = "home_notification" // 마이페이지 case mypageNotification = "mypage_notification" diff --git a/Projects/Service/PickeAnalytics/Interface/Event/ContentActionData.swift b/Projects/Service/PickeAnalytics/Interface/Event/ContentActionData.swift index c7240666..aaa05f45 100644 --- a/Projects/Service/PickeAnalytics/Interface/Event/ContentActionData.swift +++ b/Projects/Service/PickeAnalytics/Interface/Event/ContentActionData.swift @@ -21,6 +21,7 @@ public enum ContentActionType: String, Sendable { /// 홈 콘텐츠 카드가 속한 섹션. public enum ContentSection: String, Sendable { case best + case bestComment = "best_comment" case hot case new case vote diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/arrowRight.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/arrowRight.imageset/Contents.json new file mode 100644 index 00000000..96376ece --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/arrowRight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "arrowRight.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/arrowRight.imageset/arrowRight.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/arrowRight.imageset/arrowRight.svg new file mode 100644 index 00000000..791d8c23 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/arrowRight.imageset/arrowRight.svg @@ -0,0 +1 @@ + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/close.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/close.imageset/Contents.json new file mode 100644 index 00000000..1bc027f6 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/close.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "close.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/close.imageset/close.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/close.imageset/close.svg new file mode 100644 index 00000000..3527dc26 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/close.imageset/close.svg @@ -0,0 +1 @@ + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/edit.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/edit.imageset/Contents.json new file mode 100644 index 00000000..33ee9d25 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/edit.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "edit.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/edit.imageset/edit.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/edit.imageset/edit.svg new file mode 100644 index 00000000..894103b7 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/edit.imageset/edit.svg @@ -0,0 +1 @@ + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/more.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/more.imageset/Contents.json new file mode 100644 index 00000000..8a52827a --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/more.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "more.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/more.imageset/more.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/more.imageset/more.svg new file mode 100644 index 00000000..0299bf43 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/more.imageset/more.svg @@ -0,0 +1 @@ + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/report.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/report.imageset/Contents.json new file mode 100644 index 00000000..b45d938f --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/report.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "report.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/report.imageset/report.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/report.imageset/report.svg new file mode 100644 index 00000000..9e30d55d --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/report.imageset/report.svg @@ -0,0 +1 @@ + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/send.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/send.imageset/Contents.json new file mode 100644 index 00000000..a88d70f2 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/send.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "send.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/send.imageset/send.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/send.imageset/send.svg new file mode 100644 index 00000000..84dda7c7 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/send.imageset/send.svg @@ -0,0 +1 @@ + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/think.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/think.imageset/Contents.json new file mode 100644 index 00000000..a90f8e47 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/think.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "think.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/think.imageset/think.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/think.imageset/think.svg new file mode 100644 index 00000000..72d8f6f1 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/think.imageset/think.svg @@ -0,0 +1 @@ + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/trash.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/trash.imageset/Contents.json new file mode 100644 index 00000000..2860f9e3 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/trash.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "trash.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/trash.imageset/trash.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/trash.imageset/trash.svg new file mode 100644 index 00000000..720cb51d --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/trash.imageset/trash.svg @@ -0,0 +1 @@ + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/warning.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/warning.imageset/Contents.json new file mode 100644 index 00000000..968c3bd1 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/warning.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "warning.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/warning.imageset/warning.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/warning.imageset/warning.svg new file mode 100644 index 00000000..e6e77c4b --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Common/warning.imageset/warning.svg @@ -0,0 +1 @@ + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/alarm.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/alarm.imageset/Contents.json new file mode 100644 index 00000000..025888be --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/alarm.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "alarm.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/alarm.imageset/alarm.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/alarm.imageset/alarm.svg new file mode 100644 index 00000000..8290c6c5 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/alarm.imageset/alarm.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json index d1d7df50..688a5258 100644 --- a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json @@ -8,5 +8,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/clock.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/clock.imageset/Contents.json new file mode 100644 index 00000000..66b6386f --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/clock.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "clock.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/clock.imageset/clock.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/clock.imageset/clock.svg new file mode 100644 index 00000000..173dd9fb --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/clock.imageset/clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/eye.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/eye.imageset/Contents.json new file mode 100644 index 00000000..107bc195 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/eye.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "eye.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/eye.imageset/eye.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/eye.imageset/eye.svg new file mode 100644 index 00000000..3ff2d5d2 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/eye.imageset/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/replyCorner.imageset/Contents.json b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/replyCorner.imageset/Contents.json new file mode 100644 index 00000000..140d5bb6 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/replyCorner.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "replyCorner.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/replyCorner.imageset/replyCorner.svg b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/replyCorner.imageset/replyCorner.svg new file mode 100644 index 00000000..24993784 --- /dev/null +++ b/Projects/UI/PickeDesignKit/Resources/ImageAssets.xcassets/Home/replyCorner.imageset/replyCorner.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift b/Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift index 828a98c6..bf9383a4 100644 --- a/Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift +++ b/Projects/UI/PickeDesignKit/Sources/Token/Image/ImageAsset.swift @@ -44,6 +44,21 @@ public enum ImageAsset: String { case appLogo case bell + case alarm + case clock + case eye + case replyCorner + + // MARK: - 공용 아이콘 + case close + case arrowRight + case think + case more + case edit + case trash + case report + case warning + case send // MARK: - 철학자 아바타 diff --git a/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift b/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift index 702eceba..9bf66367 100644 --- a/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift +++ b/Projects/UI/PickeDesignKit/Sources/Token/Typography/PretendardFont.swift @@ -43,6 +43,37 @@ public extension View { ) ) } + + /// Figma 줄 높이에 맞춘다. `lineHeightMultiple`은 글자 크기 대비 배수(1.4 = 140%)이고 절대값이 아니다. + /// 줄 사이와 첫 줄 위·마지막 줄 아래에 남는 높이를 나눠 더하므로 배지처럼 배경 높이를 따로 정한 텍스트, + /// 높이가 고정된 컨테이너 안 여러 줄 텍스트에는 쓰지 않는다. + func pretendardFont( + _ textStyle: CustomSizeFont, + lineHeightMultiple: CGFloat + ) -> some View { + let naturalLineHeight = UIFont.pretendardFontFamily( + family: textStyle.fontFamily, + size: textStyle.size + ).lineHeight + let extra = max(0, textStyle.size * lineHeightMultiple - naturalLineHeight) + return pretendardFont(textStyle) + .lineSpacing(extra) + .padding(.vertical, extra / 2) + } + + /// Android Compose 기본 줄높이(`LineHeightStyle.Default` = Trim.Both)와 같게 줄 사이만 벌린다. + /// 첫 줄 위·마지막 줄 아래는 늘리지 않으므로 한 줄이면 글꼴 자연 높이, n줄이면 자연 높이 + (n−1) × 글자 크기 × 배수다. + func pretendardFont( + _ textStyle: CustomSizeFont, + lineSpacingMultiple: CGFloat + ) -> some View { + let naturalLineHeight = UIFont.pretendardFontFamily( + family: textStyle.fontFamily, + size: textStyle.size + ).lineHeight + return pretendardFont(textStyle) + .lineSpacing(max(0, textStyle.size * lineSpacingMultiple - naturalLineHeight)) + } } public extension UIFont { @@ -63,4 +94,12 @@ public extension Font { let font = Font.custom("PretendardVariable-\(family)", size: size) return font } + + /// Dynamic Type 에 커지지 않는 고정 크기 — `pretendardFont(_:)` 와 같은 `fixedSize`. + static func pretendardFontFamily( + family: PretendardFontFamily, + fixedSize: CGFloat + ) -> Font { + return Font.custom("PretendardVariable-\(family)", fixedSize: fixedSize) + } } diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Badge/PickeBadge.swift b/Projects/UI/PickeDesignKit/Sources/UI/Badge/PickeBadge.swift index e2212f9f..32622cb7 100644 --- a/Projects/UI/PickeDesignKit/Sources/UI/Badge/PickeBadge.swift +++ b/Projects/UI/PickeDesignKit/Sources/UI/Badge/PickeBadge.swift @@ -39,14 +39,14 @@ public enum PickeBadgeStyle: Sendable { /// 뱃지 크기. 코드베이스에 실제로 쓰이던 두 가지 형태를 그대로 옮겼다. public enum PickeBadgeSize: Sendable { - /// 카드 헤더용 — Medium 12 / px4. + /// 카드 헤더용 — Android `PickeBadge` SemiBold 12 / px4. case compact /// 태그(#카테고리)용 — SemiBold 12 / px6. case tag var font: CustomSizeFont { switch self { - case .compact: .labelSmall + case .compact: .semiBold12 case .tag: .semiBold12 } } diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift index e052603e..0939a480 100644 --- a/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift +++ b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeNavigationBar.swift @@ -82,9 +82,13 @@ public struct PickeNavigationBar: View { @ViewBuilder private var centerArea: some View { if leadingTitle == nil, let centerTitle { + // Android `PickeTopAppBarTitle` — 한 줄 말줄임, 양옆 버튼(24 + 간격) 자리를 비운다. Text(centerTitle) .pretendardFont(.headingMedium) .kerning(-0.4) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, 32) } else if let centerIcon { centerIcon .font(.system(size: 16, weight: .semibold)) diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeSegmentTab.swift b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeSegmentTab.swift index 19841325..800e3817 100644 --- a/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeSegmentTab.swift +++ b/Projects/UI/PickeDesignKit/Sources/UI/Navigaion/PickeSegmentTab.swift @@ -8,20 +8,24 @@ import SwiftUI public extension View { - /// 상단 세그먼트 탭 한 칸. 선택 시 primary 밑줄 2.5, 미선택 시 gray 베이스라인 1. + /// 상단 세그먼트 탭 한 칸 — Android `PickeTabBar`(TabRow). + /// 글자 14(선택 Bold `primary500`, 미선택 Regular `textMuted`), 안쪽 가로 4·세로 16, + /// 아래 2 구분선(`borderBeigeDefault`) 위에 선택 탭만 2 `primary500` 밑줄. 칸은 가로를 등분한다. func pickeSegmentTab(isSelected: Bool) -> some View { - pretendardFont(.labelMedium) - .foregroundStyle(isSelected ? .navigationTabTextActive : .navigationTabTextDefault) - .lineLimit(1) - .minimumScaleFactor(0.7) - .frame(maxWidth: .infinity) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(Rectangle()) - .overlay(alignment: .bottom) { - Rectangle() - .fill(isSelected ? .navigationTabBorderActive : .navigationTabBorderDefault) - .frame(height: isSelected ? 2.5 : 1) - } + pretendardFont( + family: isSelected ? .Bold : .Regular, + size: 14 + ) + .foregroundStyle(isSelected ? .primary500 : .textMuted) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.horizontal, 4) + .padding(.vertical, 16) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(isSelected ? .primary500 : .borderBeigeDefault) + .frame(height: 2) + } } } diff --git a/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift b/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift index 74470df5..b5f70911 100644 --- a/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift +++ b/Projects/UI/PickeDesignKit/Sources/UI/Skeleton/SkeletonView.swift @@ -76,3 +76,24 @@ private extension SkeletonView { date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: period) / period } } + +public extension SkeletonView { + /// Android `Modifier.shimmer()` 기본 색(surfaceBeigeStrong → surfaceBeigeDefault). + static func beige(_ shape: SkeletonShape) -> SkeletonView { + SkeletonView( + shape, + base: .surfaceBeigeStrong, + highlight: .surfaceBeigeDefault + ) + } + + /// Android `PickeSkeletonLine` — radius 4, 폭은 최대값(좁은 자리에서는 줄어든다). + static func line( + width: CGFloat, + height: CGFloat + ) -> some View { + beige(.round(cornerRadius: 4)) + .frame(maxWidth: width) + .frame(height: height) + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift index a0c6522b..95e9342e 100644 --- a/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomAlertState.swift @@ -45,6 +45,10 @@ public enum CustomAlertStyle: Equatable { case logout case withdraw case suggestTopic + /// 본문 + 취소(왼쪽 밝은)·확인(오른쪽 primary), 취소 문구가 비면 확인 1개. + case notice + /// 본문 + 확인(왼쪽 밝은)·취소(오른쪽 primary). 배경 탭(취소)이 "안내만 닫기"여야 할 때 쓴다. + case noticeReversed } @CasePathable @@ -162,4 +166,60 @@ public extension CustomAlertState where Action == CustomAlertAction { style: .suggestTopic ) } + + // MARK: - 클래스 이용권 안내 (Android 문구) + + static func classTicketRequired() -> CustomAlertState { + notice( + "클래스를 열려면 이용권이 필요해요\n클래스 기능을 먼저 알아보세요!", + confirmTitle: "뒤로가기" + ) + } + + static func classTicketExpired() -> CustomAlertState { + CustomAlertState( + title: "사용 중이던 이용권이 종료되었습니다\n새로운 이용권을 등록해주세요!", + confirmTitle: "등록하기", + cancelTitle: "뒤로가기", + style: .notice + ) + } + + /// 뒤로가기=확인(화면 닫기), 등록하기=취소(안내만 닫기). 배경 탭도 안내만 닫는다. + static func ticketAlreadyRegistered() -> CustomAlertState { + CustomAlertState( + title: "이미 등록된 이용권입니다\n다른 이용권을 등록해주세요!", + confirmTitle: "뒤로가기", + cancelTitle: "등록하기", + style: .noticeReversed + ) + } + + static func ticketInvalidCode() -> CustomAlertState { + notice("코드 번호를 다시 확인해주세요!") + } + + static func ticketRegistrationExpired() -> CustomAlertState { + notice("해당 이용권은 등록기간이 만료되었습니다") + } + + static func ticketRegisterFailed() -> CustomAlertState { + notice("코드 등록이 완료되지 않았습니다\n네트워크 확인 후 한 번 더 시도해주세요") + } + + static func ticketAlreadyActive() -> CustomAlertState { + notice("이미 사용 중인 이용권이 있어요\n만료 후 등록할 수 있어요") + } + + private static func notice( + _ title: String, + confirmTitle: String = "확인" + ) -> CustomAlertState { + CustomAlertState( + title: title, + confirmTitle: confirmTitle, + cancelTitle: "", + style: .notice + ) + } } diff --git a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift index 74f9357a..bbbdb81d 100644 --- a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Components.swift @@ -65,6 +65,16 @@ extension CustomConfirmationPopup { } } + /// primary 버튼 1개 행. + func pickeSingleButtonRow( + title: String, + action: @escaping () -> Void + ) -> some View { + pickePrimaryButton(title: title, action: action) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + @ViewBuilder private func pickeSecondaryButton( title: String, diff --git a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift index c9647f31..743ac997 100644 --- a/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift +++ b/Projects/UI/PickeSharedUI/Sources/Alert/CustomPopup/CustomConfirmationPopupView+Content.swift @@ -18,7 +18,7 @@ extension CustomConfirmationPopup { switch style { case .confirmation: return 20 - case .finalVote, .report, .alreadyWatched, .deleteConfirm, .logout, .withdraw, .suggestTopic: + case .finalVote, .report, .alreadyWatched, .deleteConfirm, .logout, .withdraw, .suggestTopic, .notice, .noticeReversed: return 0 } } @@ -40,6 +40,10 @@ extension CustomConfirmationPopup { logoutWithdrawContent case .suggestTopic: suggestTopicContent + case .notice: + noticeContent + case .noticeReversed: + noticeReversedContent } } @@ -58,6 +62,39 @@ extension CustomConfirmationPopup { } } + /// 안내 — 본문 + 취소(왼쪽 밝은)·확인(오른쪽 primary). 취소 문구가 비면 확인 버튼 1개. + @ViewBuilder + var noticeContent: some View { + pickeAlertCard { + VStack(spacing: 16) { + pickeBodyText(title) + + if cancelTitle.isEmpty { + pickeSingleButtonRow(title: confirmTitle, action: onConfirm) + } else { + pickeTwoButtonRow( + leftTitle: cancelTitle, leftAction: onCancel, + rightTitle: confirmTitle, rightAction: onConfirm + ) + } + } + } + } + + /// 안내(반대 배치) — 확인(왼쪽 밝은)·취소(오른쪽 primary). 배경 탭(취소)이 "안내만 닫기"가 되게 한다. + var noticeReversedContent: some View { + pickeAlertCard { + VStack(spacing: 16) { + pickeBodyText(title) + + pickeTwoButtonRow( + leftTitle: confirmTitle, leftAction: onConfirm, + rightTitle: cancelTitle, rightAction: onCancel + ) + } + } + } + /// 주제 제안 — 타이틀 + 본문 + 뒤로가기(왼쪽 밝은) / 제안하기(오른쪽 primary). var suggestTopicContent: some View { pickeAlertCard { diff --git a/Projects/UI/PickeSharedUI/Sources/Battle/BattleOpinionBox.swift b/Projects/UI/PickeSharedUI/Sources/Battle/BattleOpinionBox.swift new file mode 100644 index 00000000..c939a171 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Battle/BattleOpinionBox.swift @@ -0,0 +1,69 @@ +// +// BattleOpinionBox.swift +// PickeSharedUI +// +// Created by Picke on 11/10/26. +// + +import PickeDesignKit +import SwiftUI + +/// Android `BattleOpinionBox` — 프로필 40 + 입장(12 Medium)·대표(10 SemiBold) 한 줄씩. +/// 홈 "새로운 배틀" 카드와 추천 카드가 함께 쓴다. +public struct BattleOpinionBox: View { + let opinion: String + let name: String + let imageURL: URL? + + public init( + opinion: String, + name: String, + imageURL: URL? + ) { + self.opinion = opinion + self.name = name + self.imageURL = imageURL + } + + public var body: some View { + HStack(spacing: 4) { + profileImage + VStack(alignment: .leading, spacing: 2) { + Text(opinion) + .pretendardFont(.labelSmall) + .foregroundStyle(.textSubtle) + .lineLimit(1) + .truncationMode(.tail) + Text(name) + .pretendardFont(.labelXSmall) + .foregroundStyle(.textMuted) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + } + .padding(8) + .frame(maxWidth: .infinity) + .pickeCard( + .surfaceBeigeSubtle, + border: .borderBeigeDisabled, + insideBorder: true + ) + } + + /// Android `ProfileImage`(CHARACTER) — 40 원 배경 + 가운데 2/3 크기 이미지(fit). + private var profileImage: some View { + Circle() + .fill(.borderBeigeDefault) + .overlay { + if let imageURL { + PickeRemoteImage(url: imageURL) { + Color.clear + } + .content(.fit) + .frame(width: 40 * 2 / 3, height: 40 * 2 / 3) + } + } + .frame(width: 40, height: 40) + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Battle/BattleVersusBadge.swift b/Projects/UI/PickeSharedUI/Sources/Battle/BattleVersusBadge.swift new file mode 100644 index 00000000..770c7b14 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Battle/BattleVersusBadge.swift @@ -0,0 +1,23 @@ +// +// BattleVersusBadge.swift +// PickeSharedUI +// +// Created by Picke on 11/10/26. +// + +import PickeDesignKit +import SwiftUI + +/// Android 배틀 카드 VS — 40 영역 안 28 원(secondary200) + 10 SemiBold "VS". +public struct BattleVersusBadge: View { + public init() {} + + public var body: some View { + Text("VS") + .pretendardFont(.labelXSmall) + .foregroundStyle(.textDefault) + .frame(width: 28, height: 28) + .background(.secondary200, in: Circle()) + .frame(width: 40, height: 40) + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Battle/MetaLabelView.swift b/Projects/UI/PickeSharedUI/Sources/Battle/MetaLabelView.swift new file mode 100644 index 00000000..1e8ef5b0 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Battle/MetaLabelView.swift @@ -0,0 +1,50 @@ +// +// MetaLabelView.swift +// PickeSharedUI +// +// Created by Picke on 5/15/26. +// + +import PickeDesignKit +import SwiftUI + +/// 시계·눈 메타 정보 — Android `ic_clock`/`ic_eye` 아이콘(fit) + 12 Medium 텍스트. +/// 카드마다 아이콘 크기·간격·색이 달라(Android 시안) 호출부에서 넘긴다. +public struct MetaLabelView: View { + let asset: ImageAsset + let text: String + var iconSize: CGFloat = 12 + var spacing: CGFloat = 2 + var iconColor: Color = .gray400 + var textColor: Color = .gray400 + + public init( + asset: ImageAsset, + text: String, + iconSize: CGFloat = 12, + spacing: CGFloat = 2, + iconColor: Color = .gray400, + textColor: Color = .gray400 + ) { + self.asset = asset + self.text = text + self.iconSize = iconSize + self.spacing = spacing + self.iconColor = iconColor + self.textColor = textColor + } + + public var body: some View { + HStack(spacing: spacing) { + Image(asset: asset) + .resizable() + .scaledToFit() + .frame(width: iconSize, height: iconSize) + .foregroundStyle(iconColor) + .accessibilityHidden(true) + Text(text) + .pretendardFont(.labelSmall) + .foregroundStyle(textColor) + } + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Empty/PickeEmptyStateView.swift b/Projects/UI/PickeSharedUI/Sources/Empty/PickeEmptyStateView.swift index fa88746c..0c561072 100644 --- a/Projects/UI/PickeSharedUI/Sources/Empty/PickeEmptyStateView.swift +++ b/Projects/UI/PickeSharedUI/Sources/Empty/PickeEmptyStateView.swift @@ -16,24 +16,28 @@ public struct PickeEmptyStateView: View { public init( message: String, - imageAsset: ImageAsset = .noDataLogo, - imageSize: CGSize = CGSize(width: 135, height: 90) + imageAsset: ImageAsset = .appLogo, + imageSize: CGSize = CGSize(width: 160, height: 120) ) { self.message = message self.imageAsset = imageAsset self.imageSize = imageSize } + /// Android `PickeEmptyState` — 로고 160×120(`borderBeigeDefault` 색), 간격 16, 14 Regular `beige800`, 가운데 정렬. public var body: some View { - VStack(spacing: 8) { + VStack(spacing: 16) { Image(asset: imageAsset) + .renderingMode(.template) .resizable() .scaledToFit() + .foregroundStyle(.borderBeigeDefault) .frame(width: imageSize.width, height: imageSize.height) + .accessibilityLabel("빈 화면 로고") Text(message) .pretendardFont(.bodyMedium) - .foregroundStyle(.gray300) + .foregroundStyle(.beige800) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Projects/UI/PickeSharedUI/Sources/Empty/PickeLoadFailedSection.swift b/Projects/UI/PickeSharedUI/Sources/Empty/PickeLoadFailedSection.swift new file mode 100644 index 00000000..95302ec3 --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Empty/PickeLoadFailedSection.swift @@ -0,0 +1,37 @@ +// +// PickeLoadFailedSection.swift +// PickeSharedUI +// +// Created by Picke on 11/10/26. +// + +import PickeDesignKit +import SwiftUI + +/// Android `PickeLoadFailedSection` — 조회에 실패한 영역 자리에 그리는 공용 표시. +/// 테두리 1 `borderBeigeDefault` radius 2, 안쪽 16, 경고 아이콘 16 + "정보를 불러오지 못했어요" 14 Regular `textMuted`. +public struct PickeLoadFailedSection: View { + public init() {} + + public var body: some View { + HStack(spacing: 8) { + Image(asset: .warning) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .accessibilityHidden(true) + Text("정보를 불러오지 못했어요") + .pretendardFont(.bodyMedium) + } + .foregroundStyle(.textMuted) + .frame(maxWidth: .infinity) + .padding(16) + .roundedBorder( + .borderBeigeDefault, + lineWidth: 1, + radius: .radiusDefault, + insideBorder: true + ) + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Input/PickeCommentInputBar.swift b/Projects/UI/PickeSharedUI/Sources/Input/PickeCommentInputBar.swift index b96515e5..e861ec20 100644 --- a/Projects/UI/PickeSharedUI/Sources/Input/PickeCommentInputBar.swift +++ b/Projects/UI/PickeSharedUI/Sources/Input/PickeCommentInputBar.swift @@ -9,25 +9,25 @@ import SwiftUI import PickeDesignKit -/// 하단 고정 댓글 입력 바. +/// 하단 고정 댓글 입력 바 — Android `PickeCommentInput`. +/// 바 beige600 + 위 1 구분선 beige700 + 그림자, 안쪽 16·간격 8, 입력 상자 최소 52(면 inputTextfield, 테두리 1, radius 2, 안쪽 12), +/// 글자 13 Medium textDefault(힌트 textMuted), 전송 40 원 primary500 + ic_send 24(아래 6). public struct PickeCommentInputBar: View { @Binding private var text: String private let placeholder: String private let isSendEnabled: Bool private let isSubmitting: Bool - private let isCompact: Bool - private let characterLimit: Int? private let onSend: () -> Void private var focus: FocusState.Binding + /// `isCompact` 는 예전 두 모양을 고르던 값이다. Android 는 한 모양만 있어 무시한다. public init( text: Binding, focus: FocusState.Binding, placeholder: String, isSendEnabled: Bool, isSubmitting: Bool = false, - isCompact: Bool = false, - characterLimit: Int? = nil, + isCompact _: Bool = false, onSend: @escaping () -> Void ) { _text = text @@ -35,118 +35,71 @@ public struct PickeCommentInputBar: View { self.placeholder = placeholder self.isSendEnabled = isSendEnabled self.isSubmitting = isSubmitting - self.isCompact = isCompact - self.characterLimit = characterLimit self.onSend = onSend } public var body: some View { - if isCompact { - compactBar() - } else { - expandedBar() - } - } -} - -private extension PickeCommentInputBar { - @ViewBuilder - func compactBar() -> some View { - HStack(spacing: 8) { - compactTextBox - compactSendButton - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 6) - .background(.surfaceBeigeStrong, ignoresSafeAreaEdges: .bottom) - .topDivider(.beige800) - } - - var compactTextBox: some View { - TextField(placeholder, text: $text, axis: .vertical) - .pretendardFont(.regular13) - .foregroundStyle(.gray400) - .focused(focus) - .lineLimit(1 ... 4) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) - .background(.inputTextareaBackgroundDefault) - } - - var compactSendButton: some View { - Button(action: onSend) { - Image(systemName: "paperplane") - .pretendardFont(family: .Medium, size: 22) - .foregroundStyle(.iconGrayInverse) - .frame(width: 40, height: 40) - .background( - isSubmitting ? .buttonIconBackgroundDisabled : .buttonIconBackgroundDefault, - in: Circle() - ) - } - .buttonStyle(CompactSendButtonStyle()) - .disabled(!isSendEnabled) - .accessibilityLabel("보내기") - } - - @ViewBuilder - func expandedBar() -> some View { - HStack(spacing: 8) { + HStack(alignment: .bottom, spacing: 8) { textBox sendButton } - .padding(.top, 12) - .padding(.horizontal, 16) - .padding(.bottom, 24) - .frame(height: 128) - .background(.surfaceBeigeStrong) - .topDivider(.beige800) - .shadow(color: .black.opacity(0.08), radius: 6, y: -4) + .padding(16) + .topDivider(.beige700) + .background { + // 그림자는 바 배경에만 준다(안쪽 입력 상자·버튼에 번지지 않게). + Rectangle() + .fill(.beige600) + .shadow( + color: .black.opacity(0.12), + radius: 8, + y: -2 + ) + .ignoresSafeArea(edges: .bottom) + } } +} +private extension PickeCommentInputBar { var textBox: some View { - TextField(placeholder, text: $text, axis: .vertical) - .pretendardFont(.regular13) - .foregroundStyle(.gray400) - .focused(focus) - .lineLimit(1 ... 3) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(.horizontal, 12) - .padding(.top, 8) - .padding(.bottom, characterLimit == nil ? 8 : 24) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(.inputTextareaBackgroundDefault) - .overlay(alignment: .bottomTrailing) { - if let characterLimit { - Text("\(characterLimit - text.count)/\(characterLimit)") - .pretendardFont(.labelSmall) - .foregroundStyle(.gray400) - .padding(.trailing, 12) - .padding(.bottom, 8) - } - } + TextField( + "", + text: $text, + prompt: Text(placeholder).foregroundStyle(.textMuted), + axis: .vertical + ) + .pretendardFont(.medium13) + .foregroundStyle(.textDefault) + .focused(focus) + .lineLimit(1 ... 4) + .padding(12) + .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) + .background(.inputTextfieldBackgroundDefault, in: RoundedRectangle(cornerRadius: .radiusDefault)) + .roundedBorder( + .inputTextfieldBorderDefault, + lineWidth: 1, + radius: .radiusDefault, + insideBorder: true + ) } var sendButton: some View { - Button { onSend() } label: { - Image(systemName: "paperplane") - .pretendardFont(family: .Medium, size: 22) + Button(action: onSend) { + Image(asset: .send) + .renderingMode(.template) + .resizable() + .frame(width: 24, height: 24) .foregroundStyle(.iconGrayInverse) - .frame(width: 36, height: 36) - .background( - isSendEnabled ? .buttonIconBackgroundDefault : .buttonIconBackgroundDisabled, - in: Circle() - ) + .frame(width: 40, height: 40) + .background(.buttonIconBackgroundDefault, in: Circle()) } - .buttonStyle(.plain) - .disabled(!isSendEnabled) + .buttonStyle(SendButtonStyle()) + .disabled(!isSendEnabled || isSubmitting) + .padding(.bottom, 6) .accessibilityLabel("보내기") } } -private struct CompactSendButtonStyle: ButtonStyle { +private struct SendButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .opacity(configuration.isPressed ? 0.7 : 1) diff --git a/Projects/UI/PickeSharedUI/Sources/Menu/PickeMoreMenuItem.swift b/Projects/UI/PickeSharedUI/Sources/Menu/PickeMoreMenuItem.swift new file mode 100644 index 00000000..d283fa4e --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Menu/PickeMoreMenuItem.swift @@ -0,0 +1,42 @@ +// +// PickeMoreMenuItem.swift +// PickeSharedUI +// +// Created by Picke on 11/10/26. +// + +import PickeDesignKit + +/// Android `PickeMoreMenuItem` — "…" 메뉴 한 칸(아이콘 + 글자 + 동작). +public struct PickeMoreMenuItem: Identifiable { + public let title: String + public let icon: ImageAsset + public let action: () -> Void + + public var id: String { title } + + public init( + title: String, + icon: ImageAsset, + action: @escaping () -> Void + ) { + self.title = title + self.icon = icon + self.action = action + } + + /// Android `comment_menu_delete` + `ic_trash`. + public static func delete(_ action: @escaping () -> Void) -> PickeMoreMenuItem { + PickeMoreMenuItem(title: "삭제", icon: .trash, action: action) + } + + /// Android `comment_menu_edit` + `ic_edit`. + public static func edit(_ action: @escaping () -> Void) -> PickeMoreMenuItem { + PickeMoreMenuItem(title: "수정", icon: .edit, action: action) + } + + /// Android `comment_menu_report` + `ic_bell`. + public static func report(_ action: @escaping () -> Void) -> PickeMoreMenuItem { + PickeMoreMenuItem(title: "신고", icon: .report, action: action) + } +} diff --git a/Projects/UI/PickeSharedUI/Sources/Menu/PickeMoreMenuView.swift b/Projects/UI/PickeSharedUI/Sources/Menu/PickeMoreMenuView.swift new file mode 100644 index 00000000..f97576af --- /dev/null +++ b/Projects/UI/PickeSharedUI/Sources/Menu/PickeMoreMenuView.swift @@ -0,0 +1,39 @@ +// +// PickeMoreMenuView.swift +// PickeSharedUI +// +// Created by Picke on 11/10/26. +// + +import PickeDesignKit +import SwiftUI + +/// Android `PickeMoreMenu` 의 펼친 목록 — 알약(radius 24, 최소 70×34, 가로 6, primary500), +/// 아이콘 + 13 Medium 글자(iconGrayInverse) 간격 4, 항목 간격 4. +public struct PickeMoreMenuView: View { + private let items: [PickeMoreMenuItem] + + public init(items: [PickeMoreMenuItem]) { + self.items = items + } + + public var body: some View { + VStack(alignment: .trailing, spacing: 4) { + ForEach(items) { item in + Button(action: item.action) { + HStack(spacing: 4) { + Image(asset: item.icon) + .renderingMode(.template) + Text(item.title) + .pretendardFont(.medium13) + } + .foregroundStyle(.iconGrayInverse) + .padding(.horizontal, 6) + .frame(minWidth: 70, minHeight: 34) + .background(.buttonIconBackgroundDefault, in: RoundedRectangle(cornerRadius: 24)) + } + .buttonStyle(.plain) + } + } + } +} diff --git a/Tools/FigmaClassAudit/README.md b/Tools/FigmaClassAudit/README.md index 2eadc6ce..6a2b04fa 100644 --- a/Tools/FigmaClassAudit/README.md +++ b/Tools/FigmaClassAudit/README.md @@ -9,15 +9,19 @@ | 최종 투표 | `13337:57185` | | 의견 | `13384:57726` | | 결과 | `13757:17445` | +| 홈 (375×2010) | `13956:18883` | ## 실행 1. Figma 데스크톱에서 [Pické 디자인 파일](https://www.figma.com/design/6Z49tKXvCCQDd7tyGtvtiI/)을 엽니다. 원본이 보기 전용이면 **Edit file menu → Duplicate to your drafts**로 개인 사본을 열어 주세요. 노드 ID는 사본에서도 유지됩니다. 2. **Plugins → Development → Import plugin from manifest…**에서 이 폴더의 `manifest.json`을 선택합니다. 3. **Plugins → Development → Picke Class Design Audit**를 실행합니다. -4. 각 행의 **PNG + JSON** 또는 **전체 ZIP 내려받기**를 누릅니다. +4. 각 행의 **PNG + JSON** 또는 **전체 ZIP 내려받기**를 누릅니다. 단일 화면도 PNG와 JSON을 하나의 ZIP으로 저장합니다. `13956:18883`은 새로 전달된 홈 프레임입니다. 화면 내용은 내보낸 PNG·JSON으로 확인합니다. + 업데이트된 링크의 `2027:2` 아래 화면은 **업데이트된 화면 찾기**에서 이름으로 검색한 뒤 해당 행의 **PNG + JSON**을 누릅니다. 검색 결과는 최대 100개이며 프레임·섹션·컴포넌트와 상위 레이어 경로를 표시합니다. 5. Device Hub에서 앱 화면을 PNG로 캡처한 뒤 **앱 화면 대조**에서 해당 노드와 캡처를 선택합니다. **차이 이미지 만들기**를 누르면 빨간색으로 다른 픽셀과 비율을 보여 주며, 결과 PNG를 내려받을 수 있습니다. 화면만 잘라낸 같은 크기의 캡처를 사용해야 정확합니다. 내보낸 JSON에는 각 하위 노드의 이름·종류·좌표·크기·채우기·텍스트/글꼴 등 읽을 수 있는 속성이 포함됩니다. 노드가 현재 파일에 없으면 해당 ID의 오류가 표시됩니다. 비교는 기준 PNG 크기에 앱 캡처를 맞추고 RGB 채널 최대 차이 30을 넘는 픽셀을 표시합니다. 캡처 크기와 화면 상태가 다르면 수치가 높아질 수 있으므로 차이 이미지를 함께 확인하세요. 이미지와 JSON은 로컬 다운로드만 하며, 네트워크 요청이나 문서 변경은 하지 않습니다. 개발 플러그인으로 사용하므로 게시용 플러그인 ID와 빌드 도구가 필요하지 않습니다. 2026-10-27에 편집 가능한 개인 사본에서 5개 노드를 내보냈고, `picke-class-figma-audit-final.zip`의 PNG 5개·JSON 5개를 `unzip -t`로 확인했습니다. + +2026-11-01에 Pické 디자인 원본에서 개발 플러그인을 실행해 클래스·홈 6개 프레임의 PNG·JSON 12개를 로컬 `~/Downloads/picke-class-figma-audit.zip`에 저장했습니다. `2027:2`의 `클래스 참여 · iPhone 18` 프레임도 로컬 `~/Downloads/picke-class-10052-11290.zip`으로 추출했습니다. 전체 레이어 JSON은 공개 GitHub 저장소에 올리지 않습니다. diff --git a/Tools/FigmaClassAudit/code.js b/Tools/FigmaClassAudit/code.js index 4daaf978..dea06cd7 100644 --- a/Tools/FigmaClassAudit/code.js +++ b/Tools/FigmaClassAudit/code.js @@ -5,7 +5,10 @@ const targets = [ { id: "13337:57185", label: "최종 투표" }, { id: "13384:57726", label: "의견" }, { id: "13757:17445", label: "결과" }, + { id: "13956:18883", label: "홈" }, + { id: "12378:23897", label: "클래스 탭" }, ]; +const sourceNodeId = "2027:2"; figma.showUI(__html__, { width: 500, height: 760, themeColors: true }); @@ -92,13 +95,44 @@ async function exportTarget(target) { figma.ui.onmessage = async (message) => { if (message && message.type === "init") { - figma.ui.postMessage({ type: "ready", fileName: figma.root.name, targets }); + figma.ui.postMessage({ type: "ready", fileName: figma.root.name, targets, sourceNodeId }); + return; + } + if (message && message.type === "search") { + try { + const query = String(message.query || "").trim().toLocaleLowerCase(); + if (!query) throw new Error("검색어를 입력하세요."); + const source = await figma.getNodeByIdAsync(sourceNodeId); + if (!source || typeof source.findAll !== "function") { + throw new Error(`${sourceNodeId} 노드에서 하위 화면을 찾을 수 없습니다.`); + } + if (source.type === "PAGE") await source.loadAsync(); + const matches = source.findAll(node => + (node.type === "FRAME" || node.type === "SECTION" || node.type === "COMPONENT") && + node.name.toLocaleLowerCase().includes(query) + ).slice(0, 100).map(node => { + const ancestors = []; + let parent = node.parent; + while (parent && parent !== source) { + ancestors.unshift(parent.name); + parent = parent.parent; + } + return { + id: node.id, name: node.name, type: node.type, ancestors, + width: property(node, "width"), height: property(node, "height"), + }; + }); + figma.ui.postMessage({ type: "matches", query, matches }); + } catch (error) { + figma.ui.postMessage({ type: "error", message: String(error && error.message ? error.message : error) }); + } return; } if (!message || (message.type !== "export-one" && message.type !== "export-all")) return; const selected = message.type === "export-all" ? targets - : targets.filter((target) => target.id === message.id); + : [{ id: String(message.id || ""), label: String(message.label || message.id || "") }] + .filter((target) => /^\d+:\d+$/.test(target.id)); if (selected.length === 0) { figma.ui.postMessage({ type: "error", message: "지정되지 않은 노드입니다." }); return; diff --git a/Tools/FigmaClassAudit/ui.html b/Tools/FigmaClassAudit/ui.html index 00c8aa3a..ce5e4614 100644 --- a/Tools/FigmaClassAudit/ui.html +++ b/Tools/FigmaClassAudit/ui.html @@ -20,6 +20,8 @@ #status { min-height: 44px; margin-top: 14px; font-size: 12px; white-space: pre-wrap; } .error { color: #c33; } .compare { margin-top: 20px; padding: 12px; border: 1px solid var(--figma-color-border, #ddd); border-radius: 6px; } + .search { margin-top: 20px; padding: 12px; border: 1px solid var(--figma-color-border, #ddd); border-radius: 6px; } + .search input { box-sizing: border-box; width: 100%; margin: 8px 0; padding: 8px; } .compare label { display: block; margin: 8px 0; font-size: 12px; } .compare select, .compare input { max-width: 100%; margin-top: 5px; } #diff { display: none; width: 100%; margin-top: 10px; image-rendering: auto; } @@ -27,9 +29,16 @@

클래스 확정 시안 내보내기

-

열린 Figma 파일: 확인 중…
지정한 5개 노드만 읽습니다. 파일을 수정하거나 외부로 전송하지 않습니다.

+

열린 Figma 파일: 확인 중…
지정된 6개 노드와 검색한 화면을 읽습니다. 파일을 수정하거나 외부로 전송하지 않습니다.

+
앱 화면 대조

Device Hub에서 화면 부분만 잘라낸 PNG를 선택하세요. 상태 표시줄과 기기 테두리는 제외해야 합니다. 픽셀 차이는 화면 상태와 글꼴 렌더링의 영향도 받습니다.

@@ -47,6 +56,9 @@

클래스 확정 시안 내보내기

const status = document.getElementById('status'); const encoder = new TextEncoder(); const compareNode = document.getElementById('compare-node'); + const searchButton = document.getElementById('search-button'); + const query = document.getElementById('query'); + const matches = document.getElementById('matches'); const compareFile = document.getElementById('compare-file'); const compareButton = document.getElementById('compare-button'); const compareResult = document.getElementById('compare-result'); @@ -208,7 +220,33 @@

클래스 확정 시안 내보내기

compareNode.appendChild(option); } allButton.disabled = false; + searchButton.disabled = false; compareButton.disabled = false; + document.getElementById('source').textContent = '링크의 ' + message.sourceNodeId + ' 노드에서 이름을 검색합니다.'; + } else if (message.type === 'matches') { + setBusy(false); + matches.replaceChildren(); + if (!message.matches.length) matches.textContent = '일치하는 프레임이 없습니다.'; + for (const match of message.matches) { + const row = document.createElement('div'); + row.className = 'row'; + const description = document.createElement('div'); + description.className = 'node'; + const title = document.createElement('strong'); + title.textContent = match.name; + const detail = document.createElement('small'); + detail.textContent = `${match.id} · ${match.type} · ${match.width}×${match.height}`; + if (match.ancestors && match.ancestors.length) { + detail.textContent += ` · ${match.ancestors.join(' / ')}`; + } + description.append(title, detail); + const button = document.createElement('button'); + button.textContent = 'PNG + JSON'; + button.onclick = () => parent.postMessage({ pluginMessage: { type: 'export-one', id: match.id, label: match.name } }, '*'); + row.append(description, button); + matches.appendChild(row); + } + status.textContent = message.matches.length + '개 프레임을 찾았습니다.'; } else if (message.type === 'busy') { setBusy(true); status.className = ''; @@ -228,12 +266,11 @@

클래스 확정 시안 내보내기

return; } const entries = asEntries(message.results); - if (message.bundle && entries.length) { - download(zip(entries), 'picke-class-figma-audit.zip', 'application/zip'); - } else { - for (const entry of entries) { - download(entry.data, entry.name, entry.name.endsWith('.png') ? 'image/png' : 'application/json'); - } + if (entries.length) { + const name = message.bundle + ? 'picke-class-figma-audit.zip' + : fileStem(message.results[0]) + '.zip'; + download(zip(entries), name, 'application/zip'); } status.className = message.errors.length ? 'error' : ''; status.textContent = message.results.length + '개 내보냄' + (message.errors.length @@ -249,6 +286,12 @@

클래스 확정 시안 내보내기

}; allButton.onclick = () => parent.postMessage({ pluginMessage: { type: 'export-all' } }, '*'); + searchButton.onclick = () => { + setBusy(true); + status.textContent = '프레임을 찾고 있습니다…'; + parent.postMessage({ pluginMessage: { type: 'search', query: query.value } }, '*'); + }; + query.onkeydown = event => { if (event.key === 'Enter') searchButton.click(); }; compareButton.onclick = () => { if (!compareFile.files.length) { compareResult.textContent = '앱 캡처 PNG를 먼저 선택하세요.'; diff --git a/docs/analytics-mixpanel-spec-cross-platform.md b/docs/analytics-mixpanel-spec-cross-platform.md index f55321bc..f9ccaee4 100644 --- a/docs/analytics-mixpanel-spec-cross-platform.md +++ b/docs/analytics-mixpanel-spec-cross-platform.md @@ -33,7 +33,7 @@ | event | 속성(키: 타입) | 허용값 / 비고 | |---|---|---| | `screen_view` | screen: string, referrer?: string | screen = §2 화면 enum. 화면당 1회(재진입 시 재발화 OK, 화면 내 중복 금지) | -| `content_action` | action: string, content_id?: string, section?: string | action ∈ `battle_card_tap` `hero_tap` `new_battle_tap` `vote_card_tap` `vote_result_view` `quick_battle_next` `explore_category_tap` `battle_recommend_close`. section ∈ `best` `hot` `new` `vote` | +| `content_action` | action: string, content_id?: string, section?: string | action ∈ `battle_card_tap` `hero_tap` `new_battle_tap` `vote_card_tap` `vote_result_view` `quick_battle_next` `explore_category_tap` `battle_recommend_close`. section ∈ `best` `best_comment` `hot` `new` `vote` | | `ui_action` | action: string, screen: string | action = 버튼 식별자(§2.1 규약). 카드 탭은 `content_action` 사용 | ### 마이크로 (Tier 3, **초기 비활성** — 볼륨 큼, 합의 후 켬) @@ -51,7 +51,7 @@ ### 2.1 `ui_action.action` 버튼 식별자 규약 `{screen}_{button}` 형식. 예: -`home_notification`, `home_more_best`, `battle_vote_submit`, `prevote_start`, `chatroom_play`, `curation_close`, `recap_share`, `settings_logout`, `settings_withdraw`, `point_charge`, `notification_read_all`, `tab_home` `tab_explore` `tab_quick_battle` `tab_mypage` +`home_notification`, `battle_vote_submit`, `prevote_start`, `chatroom_play`, `curation_close`, `recap_share`, `settings_logout`, `settings_withdraw`, `point_charge`, `notification_read_all`, `tab_home` `tab_explore` `tab_quick_battle` `tab_mypage` > 새 버튼은 같은 규약으로 추가하고 이 문서에 등재. diff --git a/docs/class-android-ios-parity-20261029.md b/docs/class-android-ios-parity-20261029.md new file mode 100644 index 00000000..b092efa4 --- /dev/null +++ b/docs/class-android-ios-parity-20261029.md @@ -0,0 +1,64 @@ +# Class Android/iOS API 대조 (2026-10-29) + +기준: [Android `develop` `05c14af`](https://github.com/SWYP-Find/Picke-android/tree/05c14af23f0246e0e0f6d49e835dfb5151828696), [서버 클래스 API 명세 `1dc6db2`](https://github.com/SWYP-Find/Server/blob/1dc6db2c5de786bd7ba51ec3a052724102010c27/docs/api-specs/class-api.md), 이 저장소의 현재 작업 브랜치. Android의 열린 PR은 없으며 [#135 AI 토론](https://github.com/SWYP-Find/Picke-android/pull/135), [#137 결과](https://github.com/SWYP-Find/Picke-android/pull/137), [#139 후속 수정](https://github.com/SWYP-Find/Picke-android/pull/139)이 머지된 상태다. 코드와 명세의 정적 대조이며 인증된 실서버 호출이나 화면 일치의 증거는 아니다. + +## Endpoint + +Android `ClassroomApi` 30개, `VoteApi`의 클래스 전용 2개, `PerspectiveApi`의 클래스 전용 3개에 있는 클래스 관련 endpoint 35개 모두 iOS에 대응 구현이 있다. iOS 경로는 `Projects/Service/APIEndpoint/Sources/Class/`의 Ticket, Room, Member, Battle, TopicDraft, Recommendation 서비스에 있다. 공통 요청의 경로·메서드·주요 body/query 필드는 일치한다. + +| 영역 | Android | iOS와 서버 명세 대조 | +| --- | --- | --- | +| 이용권·클래스·가입·멤버 | `ClassroomApi.kt` | 19개 endpoint 대응. 클래스 생성의 선택적 `ownerDisplayName`·`requestKey`, 초안 제안의 선택적 `filter`는 iOS가 추가 지원한다. | +| 투표·의견 | `ClassroomApi.kt`, `VoteApi.kt`, `PerspectiveApi.kt` | 8개 endpoint 대응. iOS는 중간 투표와 투표 이력의 `midVotes`를 추가 지원한다. | +| Android에 아직 없는 서버 기능 | 해당 API 없음 | iOS는 초안 조회·직접 수정 검증과 중간 투표를 추가 구현했다. Android에도 AI 토론·답글·재시도, 결과·내 리포트·인사이트·재시도 API와 ViewModel 연결이 머지됐다. | + +## 계약 차이와 처리 + +| 항목 | 확인 결과 | +| --- | --- | +| 클래스 의견 작성자 `userTag` | 서버 응답은 `null`일 수 있고 Android DTO도 nullable이다. iOS DTO를 optional로 수정하고 도메인 매핑에서 빈 문자열로 처리했다. | +| 내 의견 조회 | Android `PerspectiveApi.kt`는 단일 DTO를 반환하도록 선언했지만 서버 명세는 배열이다. iOS 배열 파싱을 유지한다. | +| 배틀 추천 선택지 | Android DTO의 `label/rationale/philosopher*`는 구형 형태다. 서버와 iOS는 `optionId/position/title/stance/representative/imageUrl`을 사용한다. | +| TTS 완료 호출 | Android는 최종 투표 제출 직전에 호출한다. iOS는 재생 완료 단계에서 호출하며, 서버의 재생 완료 시 호출 계약에 부합한다. | +| 필수 의견 삭제 | 서버는 클래스 설정에 따라 삭제를 거부할 수 있다. iOS는 실패 후 화면을 닫던 동작을 수정해 오류 알림을 표시한다. | +| 초대코드 실패 표시 | Android는 미분류 요청 실패를 일반 토스트로 표시한다. iOS 실기동에서는 인증 재시도 오류의 내부 타입 문자열이 코드 입력 화면에 그대로 노출됐다. `ClassError.unknown` 표시 문구를 일반 요청 오류로 바꿨다. | +| 401 재시도 | Android `TokenAuthenticator`는 재발급 뒤에도 401이면 두 번째 응답에서 재시도를 멈춘다. iOS는 실제 초대코드 조회에서 Alamofire `excessiveRefresh`가 발생했다. 서버가 새 토큰을 거부하는지, 토큰 저장·갱신 경로가 어긋나는지는 추가 인증 증거가 필요하다. | + +최신 Android AI 토론·결과 ViewModel은 실제 Repository를 호출한다. Android 결과 카드의 0명 상태는 `아직 참여 없음`과 중립색 도넛을 사용하고, 비율은 응답의 인원수로 계산한다. iOS 결과 화면도 인원수 기반 비율, 0명 문구·중립색 도넛, 참여 설명 문구와 6pt 막대로 맞췄다. 별도 Chrome 탭의 최신 Figma 파일에서 결과 프레임 `13757:17445`를 2026-10-29에 다시 내보내 [`docs/evidence/class-result-figma-20261029.png`](evidence/class-result-figma-20261029.png)에 보관했다. PNG SHA-256 `2720fafbbfb6b68f5d22eaae0cc57851c07e9a636007064eacc7e77954cf8d15`는 2026-10-27 플러그인 추출본과 완전히 같았다. 따라서 이 결과 프레임 자체의 시안은 바뀌지 않았다. 사용자가 준 `2027:2`는 이 파일의 와이어프레임/Hifi 페이지를 열었으며, 그 아래 다른 변경 화면의 전체 추출·대조는 남아 있다. + +## 이번 실행 검증 + +- iPhone 18 Pro (`68DAE7F3-C265-46F4-9FEE-71140108E210`) 대상 `tuist test ClassDomain` 성공. Foundation Models 기기 전용 테스트 4개는 시뮬레이터에서 건너뛰었다. +- 같은 대상 `tuist test Chat`, `tuist test PickeNetwork`, 최종 `tuist build Picke-Stage` 성공. 빌드된 Stage 앱을 시뮬레이터에 설치하고 실행해 클래스 진입 화면까지 확인했다. +- Device Hub에서 앱 홈과 클래스 진입 화면은 표시됐다. `내 클래스` 목록은 재시도 후에도 오류 화면이었고, 앱 로그에서 `GET /api/v1/classes`의 서버 `AUTH_401`을 확인했다. 따라서 제공된 테스트 클래스·초대코드·이용권 코드의 인증된 실제 응답은 확인하지 못했다. +- 최종 설치 앱을 iPhone 18 Pro Device Hub에서 다시 조작했다. `내 클래스`는 오류 화면이었고, `클래스 참여`에서 테스트 초대코드 여섯 글자 입력 및 활성화된 버튼까지 시각 확인했다. 버튼을 누른 실제 `GET /api/v1/classes/join/preview?inviteCode=`는 서버 `AUTH_401`로 끝났으며 화면에는 `AuthenticationError.excessiveRefresh`가 포함된 내부 오류가 표시됐다. +- 오류 문구 수정 후 `tuist test ClassDomain`과 `tuist build Picke-Stage`가 성공했다. 새 Stage 앱을 같은 iPhone 18 Pro에 설치·실행해 동일한 초대코드를 다시 요청했다. 응답 실패는 계속됐지만, 화면에는 `클래스 요청을 처리하지 못했습니다. 다시 시도해 주세요.`만 표시되고 내부 오류 문자열은 사라진 것을 Device Hub 접근성 트리와 스크린샷으로 확인했다. +- Android `ClassJoinCodeStep.kt`와 iOS `ClassJoinView.swift`의 코드 입력 UI는 모두 6칸, 대문자 ASCII 입력, 코드 검증 후 이름 입력의 순서다. 실제 Android 실행 화면이나 최신 Figma 캡처가 없어 색·간격·폰트의 일치 여부는 판정하지 않았다. +- Figma 개발 플러그인에는 `2027:2` 하위 화면 검색과 PNG/JSON 내보내기를 추가했고 JavaScript 문법 검사를 통과했다. 별도 Chrome 탭에서 Figma 로그인을 완료하고 결과 프레임 PNG를 다시 내보내 기존 플러그인 PNG와 SHA-256 일치를 확인했다. 새 디자인 전체에 대한 플러그인 실행과 앱 화면의 픽셀 대조는 아직 실행하지 못했다. 사용 중인 원래 탭은 건드리지 않았다. +- API 로그 진단 중 인증 헤더가 그대로 기록되는 문제를 발견해 향후 로그에서 인증 헤더와 인증 API 본문을 가리도록 수정했다. `PickeNetwork` 테스트가 통과했다. +- 2026-10-30에 전체 워크스페이스를 재생성한 뒤 단일 iPhone 18 Pro 목적지로 `xcodebuild -workspace Picke.xcworkspace -scheme Picke-Stage ... build`를 실행해 종료 코드 0을 확인했다. `Class`와 `Picke-Stage`의 `build-for-testing`도 종료 코드 0으로 성공했다. +- 새 Stage 앱을 iPhone 18 Pro에 설치·실행해 다시 확인했다. `GET /api/v1/classes`와 `GET /api/v1/classes/join/preview?inviteCode=`가 모두 `dev.picke.store`에서 `401 / AUTH_401`을 반환했다. 초대코드 입력 화면에는 일반 오류 문구만 표시되고 인증 헤더는 로그에서 ``로 가려졌다. 이 계정의 클래스 상세·가입·이용권 등록은 진행할 수 없었다. +- `ClassReportTests` 실행은 5분 이상 결과가 없어 중단했다. 한 테스트만 지정한 실행은 종료 코드 0이었으나 결과 번들의 `totalTestCount=0`이라 통과로 간주하지 않는다. 결과 화면 캡처 테스트는 한 번 건너뛰기(`PICKE_CLASS_VISUAL_AUDIT`가 테스트 호스트에 미전달)를 확인했고, 환경변수를 넣은 재실행은 4분 넘게 완료되지 않아 중단했다. 테스트 타깃 컴파일은 성공했지만 실제 캡처와 Figma 픽셀 비교는 미완료다. +- 2026-10-30에 추가 전달된 Figma 노드 `13956:18883`을 감사 플러그인의 기본 내보내기 대상에 추가했다. 별도 Chrome 창의 Figma 내보내기로 `홈` 프레임(375×2010)의 2배율 PNG [`docs/evidence/home-figma-13956-18883-20261030.png`](evidence/home-figma-13956-18883-20261030.png)를 확보했다(750×4020, SHA-256 `6d6741a75f6538fd5d79fef7b193ad89686ce25ffc97702d93d6db0a3f82e99e`). 개발 플러그인은 등록됐지만 Figma Desktop 공유 창이 가로막아 실행·JSON 추출은 확인하지 못했다. 사용 중인 원래 탭과 FESTICORE 창은 변경하지 않았다. +- 동일한 iPhone 18 Pro 설치 앱 로그에서 클래스 API뿐 아니라 출석 체크·주간 출석·읽지 않은 알림도 `AUTH_401`로 끝났다. 따라서 클래스 전용 endpoint 문제보다 현재 저장된 로그인 세션 또는 공통 인증 갱신의 문제일 가능성이 크다. 갱신 요청의 최종 HTTP 응답은 확인되지 않았으므로 원인은 확정하지 않았다. +- iOS 인증 코드와 Android `TokenAuthenticator`·`AuthInterceptor`를 다시 정적 대조했다. Bearer 주입, `POST /api/v1/auth/refresh`와 `X-Refresh-Token`, 새 토큰 저장·공유 인터셉터 갱신 경로에 규약 불일치는 발견하지 못했다. 새 CoreSimulator 로그 조회는 샌드박스 접근 오류로 실패했다. 기존 요청 401 → 갱신 HTTP 상태 → 갱신 뒤 재요청 상태를 비밀값 없이 확보하기 전까지 특정 코드·서버·토큰을 원인으로 단정하지 않는다. +- Device Hub의 iPhone 18 Pro에서 이전에 실제 홈 화면의 헤더·대표 배틀·Best 배틀·하단 탭을 확인했다. 이후 같은 시뮬레이터의 전면 앱이 FESTICORE Design Kit으로 바뀌어 현재 Pické 화면 캡처를 얻지 못했다. 다른 작업을 방해하지 않기 위해 앱 전환·재설치는 하지 않았다. 따라서 새 Figma PNG와 iOS의 픽셀 비교·시각 일치 판정은 미완료다. + +## Figma 홈 `13956:18883` 정적 대조 (2026-10-30) + +Figma PNG에는 대표 배틀, `지금 뜨는 배틀`, `Best 배틀`, `Best 댓글`, `새로운 배틀`이 순서대로 있다. 현재 iOS `HomeView`는 대표 배틀·뜨는 배틀·Best 배틀·조건부 `오늘의 Pické`·새로운 배틀을 표시하고, 광고를 삽입한다. Android `05c14af`의 `HomeScreen`은 뜨는 배틀이 0건이어도 제목과 빈 카드를 표시하고, 광고를 삽입하며, `오늘의 Pické`는 홈에서 표시하지 않도록 주석 처리했다. Android에도 `Best 댓글` 홈 섹션은 없다. iOS `GET /api/v1/home` 응답 모델과 Android 홈 화면 모델에는 Best 댓글 목록 필드가 없어 실제 데이터 기반으로 시안의 이 섹션을 그릴 수 없다. 화면 데이터가 다른 상태이므로 카드 글자·사진의 픽셀 차이는 UI 구현 결함으로 판정하지 않았다. + +## 전달 상태 + +2026-10-30 GitHub에서 [PR #78](https://github.com/SWYP-Find/Picke-iOS/pull/78)의 `develop` 머지(`1b9ed1d`)와 [PR #79](https://github.com/SWYP-Find/Picke-iOS/pull/79)의 열린 Draft 상태를 다시 확인했다. 현재 작업 트리의 추가 수정은 #79에 반영되지 않았다. 인증 API 성공, Figma 화면 실측 비교, 전체 테스트 실행 및 TestFlight 업로드가 끝나지 않아 이번 추가 작업을 배포 완료로 표시하지 않는다. Claude CLI 계획 검토와 구현 후 점검은 각각 시간 초과로 종료돼 교차 검토 승인 증거도 없다. + +## 2026-11-01 재검증과 최신 디자인 추출 + +- Figma Desktop에서 이 저장소의 `Picke Class Design Audit` 개발 플러그인을 실제 실행했다. 지정된 클래스 5개와 홈 1개의 PNG 및 레이어 JSON 12개 파일을 로컬 `~/Downloads/picke-class-figma-audit.zip`으로 내보냈다. `2027:2` 아래에서 `클래스 참여 · iPhone 18` 프레임 `10052:11290`(402×874)을 검색해 PNG와 JSON을 로컬 `~/Downloads/picke-class-10052-11290.zip`에 저장했다. 전체 레이어 JSON은 공개 GitHub 저장소의 PR에 포함하지 않는다. 단일 화면도 한 ZIP으로 내려받도록 플러그인을 수정해 Figma Desktop의 연속 다운로드 차단을 피했다. +- 추출한 iPhone 18 참여 프레임은 코드 입력을 한 개의 테두리 상자로 그리고 코드 찾기 안내 카드를 둔다. 최신 Android `ClassJoinCodeStep`과 iOS `ClassJoinView`는 6칸 코드 입력을 사용한다. 이 프레임에는 두 개의 하단 CTA가 겹쳐 보여 확정 화면인지 판단할 추가 맥락이 필요하다. 따라서 이 프레임 하나만으로 기존 양 플랫폼의 입력 흐름을 변경하지 않았다. +- 최신 Android `develop` `34448b4`는 홈의 `Best 댓글` 섹션과 nullable `bestComments` 응답 필드를 추가했다. 따라서 위 2026-10-30 대조의 “Android에도 Best 댓글 홈 섹션은 없다”는 문장은 현재 기준으로 유효하지 않다. 서버 `a08c30f`의 `HomeResponse`와 홈 API 명세에는 해당 필드가 없으므로 실데이터 표시 여부는 API 계약 업데이트가 필요하다. 최신 Android 홈에는 `오늘의 Pické` 섹션이 없고, 뜨는 배틀이 0건이어도 빈 상태 카드가 표시된다. +- iPhone 18 Pro 시뮬레이터의 기존 `io.Picke.co` 앱을 실행해 실제 로그인된 홈과 광고 팝업을 캡처했다. Device Hub는 `cgWindowNotFound`로 연결되지 않아 팝업을 닫은 화면 및 클래스 화면의 새 픽셀 대조를 아직 완료하지 못했다. 이 캡처는 새 코드가 빌드·설치된 증거가 아니다. +- 코드 리뷰에서 이용권 코드가 GET 쿼리의 전체 URL 로그에 남는 경로를 확인했다. 네트워크 로그는 URL 경로만 기록하고 인증·클래스·이용권 API의 요청/응답 본문을 가리도록 수정했다. 미분류 내부 오류는 일반 문구로 표시하고, 앱이 명시적으로 만든 사용자 안내는 별도 `userMessage` 타입으로 유지한다. +- `xcodebuild -workspace Picke.xcworkspace -scheme Picke-Stage -configuration Stage -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build -quiet`가 종료 코드 0으로 성공했다. 새 `io.Picke.co` 앱 번들을 iPhone 18 Pro에 설치·실행했고 스플래시 뒤 SNS 로그인 화면으로 이동하는 것을 `simctl` 스크린샷으로 확인했다. 이전 로그인 세션이 유지되지 않아 이 실행에서는 홈·클래스·이용권의 인증된 API와 새 UI를 검증하지 못했다. Device Hub 재연결도 화면 캡처 오류 `-10005`로 실패했다. +- Home Stage 빌드가 성공했고 HomeDomain `HomeRepositoryTests` 4/4, Home 테스트 5/5가 실제 xcresult에서 통과했다. ClassDomain의 `ClassBattleAPIContractTests`와 `ClassErrorMappingTests` 9/9, `ClassMemberAPITests` 3/3, Class `ClassReportTests` 12/12도 통과했다. 첫 ClassReport 실행에서 비율 기대값이 잘못된 테스트 1건을 수정한 뒤 재실행해 12/12를 확인했다. +- Claude CLI 구현 후 교차 점검은 샌드박스 DNS 실패 후 네트워크 제한 밖에서 재시도했으나 180초 시간 초과로 종료됐다. 승인 또는 코드 검토 완료로 간주하지 않는다. PR #79의 새 변경분은 아직 원격 CI를 통과한 상태가 아니며, 인증된 실화면·API 확인과 Figma 픽셀 비교도 남아 있다. diff --git a/docs/evidence/class-result-figma-20261029.png b/docs/evidence/class-result-figma-20261029.png new file mode 100644 index 00000000..35b8d542 Binary files /dev/null and b/docs/evidence/class-result-figma-20261029.png differ diff --git a/docs/evidence/home-figma-13956-18883-20261030.png b/docs/evidence/home-figma-13956-18883-20261030.png new file mode 100644 index 00000000..32cec15a Binary files /dev/null and b/docs/evidence/home-figma-13956-18883-20261030.png differ