Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f2c81df
클래스 중간 투표 API와 분기 재생 흐름 연결
Roy-wonji Oct 28, 2026
adfe235
클래스 생성 대상별 요청 키 분리
Roy-wonji Oct 28, 2026
d99adc0
클래스 API와 홈 디자인 정합성 반영
Roy-wonji Nov 1, 2026
7cead4a
클래스 투표·청취·의견·결과 화면 Figma 정합성 반영
Roy-wonji Nov 2, 2026
f451f73
클래스 Figma 대조용 캡처 테스트와 추출 대상 추가
Roy-wonji Nov 2, 2026
64707f0
클래스 화면 Figma 줄 높이·간격 2차 정합성 반영
Roy-wonji Nov 2, 2026
09b1959
클래스 결과 내 활동 API 연동
Roy-wonji Nov 2, 2026
7f4eec0
의견 중복 신고를 서버 오류 코드로 구분
Roy-wonji Nov 2, 2026
b6eabc0
클래스 리포트와 내가 남긴 의견을 내 활동 API로 연결
Roy-wonji Nov 3, 2026
a3c1716
클래스 의견 더보기 메뉴로 신고·수정·삭제 연결
Roy-wonji Nov 3, 2026
96de226
클래스 의견 메뉴 점검 지적 반영
Roy-wonji Nov 3, 2026
e2a1880
클래스 의견 참여 판정을 내 의견 빈 응답에도 유지
Roy-wonji Nov 3, 2026
fe837df
운영자 대시보드 내 투표 결과 활동 행을 3열로 변경
Roy-wonji Nov 3, 2026
8d23d20
내가 남긴 의견 화면을 닫으면 진행 중인 조회 취소
Roy-wonji Nov 3, 2026
cc95774
클래스 코디네이터 피드백 테스트의 uuid 의존성 오류 수정
Roy-wonji Nov 3, 2026
13367d7
리포트 화면을 닫으면 투표 기록·인사이트 요청도 취소
Roy-wonji Nov 3, 2026
16e1590
운영자 대시보드 내가 남긴 의견 문구를 리포트와 맞춤
Roy-wonji Nov 3, 2026
316f478
내 의견 다시 시도 시 비어 있는 의견 목록도 다시 조회
Roy-wonji Nov 3, 2026
1e8b7c4
이용권 상태값을 모르면 만료로 처리
Roy-wonji Nov 7, 2026
3bb9296
공용 팝업에 안내 스타일과 이용권 안내 문구 추가
Roy-wonji Nov 7, 2026
ba48891
클래스 이용권 동작을 Android와 맞춤
Roy-wonji Nov 7, 2026
febb9fe
홈 Best 댓글 답글 아이콘 에셋 추가
Roy-wonji Nov 7, 2026
bec874b
분석 이벤트 section 에 best_comment 추가
Roy-wonji Nov 7, 2026
f2e0ada
홈 화면을 Android 개편 시안에 맞춤
Roy-wonji Nov 7, 2026
6a8aaab
홈 Best 댓글 배지를 첫 줄 기준선에 겹쳐 행 높이를 Android와 맞춤
Roy-wonji Nov 7, 2026
3caeb39
홈 실패 후 재진입 테스트가 effect 완료를 기다리도록 수정
Roy-wonji Nov 7, 2026
5de25aa
홈 알림·시계·눈 아이콘 에셋 추가
Roy-wonji Nov 8, 2026
df3e8cf
DesignKit 에 Android 줄높이와 같은 줄 간격 글꼴 함수 추가
Roy-wonji Nov 8, 2026
d21cc0c
홈 재선택·새로고침·실패 안내 동작을 Android와 맞춤
Roy-wonji Nov 8, 2026
1dcd03d
홈 상단 바·EDITOR PICK·배틀 카드·빈 화면을 Android 시안에 맞춤
Roy-wonji Nov 8, 2026
a24388f
홈 섹션 헤더·Best 배틀·Best 댓글을 Android 실제 줄높이로 다시 맞춤
Roy-wonji Nov 8, 2026
d8c939c
홈 스켈레톤을 Android HomeSkeleton 구성으로 교체
Roy-wonji Nov 8, 2026
43070c4
홈 2차 정합 점검 지적 반영
Roy-wonji Nov 8, 2026
a0e4abc
홈 배틀 카드 부품을 공용 UI로 옮기고 닫기 아이콘 추가
Roy-wonji Nov 9, 2026
9a45e71
추천 화면을 Android RecommendScreen 에 맞춤
Roy-wonji Nov 9, 2026
4f52fbc
공용 탭·빈 상태·배지·상단 바 제목을 Android 컴포넌트에 맞춤
Roy-wonji Nov 9, 2026
745bc5c
관점 목록 화면을 Android PerspectiveScreen 에 맞춤
Roy-wonji Nov 9, 2026
5d50a72
공용 입력창·더보기 메뉴를 Android PickeCommentInput·PickeMoreMenu 에 맞춤
Roy-wonji Nov 9, 2026
e12c061
댓글 화면을 Android CommentScreen 에 맞춤
Roy-wonji Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Projects/App/AuditHost/Sources/MainTabAuditView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
63 changes: 58 additions & 5 deletions Projects/App/Sources/Navigation/Class/ClassCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -190,7 +190,8 @@ public struct ClassCoordinator {
effect,
cancelRemovedReportLoads(state: &state),
cancelRemovedChatObservers(state: &state),
cancelRemovedClassEffects(state: &state)
cancelRemovedClassEffects(state: &state),
cancelRemovedMyOpinionsLoads(state: state)
)
}
}
Expand Down Expand Up @@ -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)),
Expand All @@ -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) }
Expand All @@ -481,6 +488,20 @@ private extension ClassCoordinator {
return .merge(removedIDs.map { .cancel(id: ChatRoomFeature.CancelID.audioObserver($0)) })
}

/// 내 의견 화면은 항상 최상단 하나뿐이고 CancelID가 전역이므로, 라우트에 없으면 남은 조회를 모두 취소한다.
func cancelRemovedMyOpinionsLoads(state: State) -> Effect<Action> {
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<Action> {
let reportIDs = Set(state.routes.compactMap { route -> UUID? in
guard case let .report(report) = route.screen else { return nil }
Expand All @@ -500,14 +521,18 @@ 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
[
.cancel(id: ClassOwnerDashboardFeature.CancelID.load(id)),
.cancel(id: ClassOwnerDashboardFeature.CancelID.generate(id)),
.cancel(id: ClassOwnerDashboardFeature.CancelID.generatePersonal(id)),
.cancel(id: ClassOwnerDashboardFeature.CancelID.myActivity(id)),
]
}
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 23 additions & 9 deletions Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

경로: 마이 탭에서 이용권 화면에 진입 시 탭바 표시
원인: 이전 코드(return false)는 이용권 화면이 루트에 있으면 항상 탭바를 숨겼습니다. 변경된 코드(return ticket.customAlert != nil)는 이용권 화면에 customAlert이 있을 때만 탭바를 숨기므로, 일반적인 이용권 등록/개요 화면에서는 탭바가 계속 표시됩니다.
수정 방향: 이용권 화면에서 customAlert이 없는 경우에도 탭바가 숨겨져야 하는지, 혹은 현재 의도대로 customAlert이 있는 경우에만 숨기는 것이 맞는지 디자인/기획 의도를 재확인하여 탭바 표시 동작을 일관성 있게 맞춰야 합니다.

return myPageState.routes.count > 1
case .none:
return false
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -233,7 +240,14 @@ private extension MainTabCoordinator {
) -> Effect<Action> {
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:
Expand Down
31 changes: 30 additions & 1 deletion Projects/App/Sources/Navigation/Profile/ProfileCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions Projects/App/Tests/Sources/ClassCoordinatorFeedbackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [P2] Major

TestStore 외부에서 ClassDetailFeature.State를 초기화할 때 uuid 의존성이 주입되지 않아 테스트가 불안정해질 수 있습니다. withDependencies 클로저 내에서 ClassDetailFeature.State를 생성하여 uuid 의존성을 명시적으로 제공한 것은 올바른 수정입니다.

$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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<T: Sendable>(
request: @escaping @Sendable (CheckedContinuation<T, Error>) -> Request
) async throws -> T {
Expand Down
Loading
Loading