From 708651839cf2d9ef841c7d6c2f71cd2d79f43930 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 26 Oct 2026 12:58:08 +0900 Subject: [PATCH] =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=EA=B6=8C=20=EB=8F=99=EC=84=A0=EA=B3=BC=20AI=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=A7=A4=ED=95=91=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클래스 이용권 진입 시 마이페이지 화면과 탭 표시를 맞추고, AI 생성 상태를 enum으로 처리합니다. 참여 화면 포커스 정리와 TestFlight API 키 전달도 보완합니다. --- .../MainTab/MainTabCoordinator.swift | 12 +++++++++ .../ProfileTicketCoordinatorTests.swift | 2 +- .../Model/DTO/ClassAIFeedbackDTO.swift | 6 ++--- .../Model/DTO/ClassAIGenerationState.swift | 26 +++++++++++++++++++ .../Model/Mapper/ClassAIFeedbackDTO+.swift | 18 ++++++------- .../ClassRepositoryImpl+Result.swift | 2 +- .../ClassJoin/View/ClassJoinView.swift | 16 ++++++++++-- .../ClassTicket/View/ClassTicketView.swift | 2 +- fastlane/Fastfile | 2 -- 9 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIGenerationState.swift diff --git a/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift index 2a832457..c2c59f53 100644 --- a/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift +++ b/Projects/App/Sources/Navigation/MainTab/MainTabCoordinator.swift @@ -73,6 +73,7 @@ public struct MainTabCoordinator { case .classroom: return !classState.showsTabBar case .myPage: + if case .ticket? = myPageState.routes.last?.screen { return false } return myPageState.routes.count > 1 case .none: return false @@ -174,6 +175,17 @@ public struct MainTabCoordinator { state.selectedTab = state.backDestinationTab return .none + case .classroom(.router(.routeAction(0, action: .intro(.delegate(.ticket))))): + state.classState.routes.goBackToRoot() + state.myPageState.routes.goBackToRoot() + state.myPageState.routes.push(.ticket(.init())) + 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)) diff --git a/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift b/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift index 3cb8453e..3c280982 100644 --- a/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/ProfileTicketCoordinatorTests.swift @@ -24,7 +24,7 @@ struct ProfileTicketCoordinatorTests { #expect(store.state.selectedTab == myPageTab) #expect(store.state.myPageState.routes.count == 2) - #expect(store.state.shouldHideTabBar) + #expect(!store.state.shouldHideTabBar) guard case let .ticket(ticket)? = store.state.myPageState.routes.last?.screen else { Issue.record("이용권 등록 화면이 열리지 않았습니다") return diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift index 08c157a4..40bce229 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIFeedbackDTO.swift @@ -34,7 +34,7 @@ struct ClassPersonalReportDTO: Decodable, Sendable { } - let state: String + let state: ClassAIGenerationState let feedback: Feedback? let preOption: VoteOption? let postOption: VoteOption? @@ -52,7 +52,7 @@ struct ClassInsightDTO: Decodable, Sendable { } - let state: String + let state: ClassAIGenerationState let retryable: Bool let insightText: String? let evidence: [Evidence]? @@ -62,5 +62,5 @@ struct ClassInsightDTO: Decodable, Sendable { } struct ClassInsightRetryDTO: Decodable, Sendable { - let state: String + let state: ClassAIGenerationState } diff --git a/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIGenerationState.swift b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIGenerationState.swift new file mode 100644 index 00000000..d95f4174 --- /dev/null +++ b/Projects/Domain/ClassDomain/Sources/Model/DTO/ClassAIGenerationState.swift @@ -0,0 +1,26 @@ +import Foundation + +enum ClassAIGenerationState: Decodable, Equatable, Sendable { + case beforeClose + case pending + case generating + case succeeded + case limited + case notEligible + case failed + case unknown + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer().decode(String.self) + switch value { + case "BEFORE_CLOSE": self = .beforeClose + case "PENDING": self = .pending + case "GENERATING": self = .generating + case "SUCCEEDED": self = .succeeded + case "LIMITED": self = .limited + case "NOT_ELIGIBLE": self = .notEligible + case "FAILED": self = .failed + default: self = .unknown + } + } +} diff --git a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift index ffae479f..51494e86 100644 --- a/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift +++ b/Projects/Domain/ClassDomain/Sources/Model/Mapper/ClassAIFeedbackDTO+.swift @@ -48,7 +48,7 @@ extension ClassPersonalReportDTO.Feedback { extension ClassPersonalReportDTO { func toDomain() throws -> ClassAIPersonalFeedback { switch state { - case "SUCCEEDED", "LIMITED": + case .succeeded, .limited: guard let feedback else { throw ClassAIFeedbackError.generationFailed } return feedback.toDomain( preOption: preOption, @@ -56,13 +56,13 @@ extension ClassPersonalReportDTO { perspectiveCount: perspectiveCount ?? 0, commentCount: commentCount ?? 0 ) - case "BEFORE_CLOSE", "PENDING", "GENERATING": + case .beforeClose, .pending, .generating: throw ClassAIFeedbackError.modelNotReady - case "NOT_ELIGIBLE": + case .notEligible: throw ClassAIFeedbackError.insufficientInput - case "FAILED": + case .failed: throw ClassAIFeedbackError.generationFailed - default: + case .unknown: throw ClassAIFeedbackError.generationFailed } } @@ -82,7 +82,7 @@ extension ClassInsightDTO.Evidence { extension ClassInsightDTO { func toDomain() throws -> ClassAIInsight { switch state { - case "SUCCEEDED", "LIMITED": + case .succeeded, .limited: guard let insightText, !insightText.isEmpty else { throw ClassAIFeedbackError.generationFailed } return ClassAIInsight( text: insightText, @@ -91,11 +91,11 @@ extension ClassInsightDTO { coverage: coverage, excludedCount: excludedCount ?? 0 ) - case "BEFORE_CLOSE", "PENDING", "GENERATING": + case .beforeClose, .pending, .generating: throw ClassAIFeedbackError.modelNotReady - case "FAILED": + case .failed: throw retryable ? ClassAIFeedbackError.serverRetryAvailable : .generationFailed - default: + case .notEligible, .unknown: throw ClassAIFeedbackError.generationFailed } } diff --git a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift index 6f7c8789..cbf51ed7 100644 --- a/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift +++ b/Projects/Domain/ClassDomain/Sources/Repository/ClassRepositoryImpl+Result.swift @@ -46,7 +46,7 @@ extension ClassRepositoryImpl { public func retryInsight(roomID: Int) async throws { do { let payload = try await client.send(ClassResultService.retryInsight(roomID), as: ClassInsightRetryDTO.self) - guard payload.state == "PENDING" else { throw ClassAIFeedbackError.generationFailed } + guard payload.state == .pending else { throw ClassAIFeedbackError.generationFailed } } catch { throw Self.feedbackError(error) } diff --git a/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift b/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift index 13c6b38e..e45168e6 100644 --- a/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift +++ b/Projects/Feature/Class/Sources/ClassJoin/View/ClassJoinView.swift @@ -24,7 +24,10 @@ public struct ClassJoinView: View { public var body: some View { VStack(spacing: 0) { PickeNavigationBar( - onBack: { send(.backTapped) }, + onBack: { + resignInputFocus() + send(.backTapped) + }, centerTitle: store.navigationCenterTitle, leadingTitle: store.navigationLeadingTitle, leadingSpacing: 12 @@ -59,6 +62,7 @@ public struct ClassJoinView: View { isCodeFocused = store.mode == .code isNicknameFocused = store.mode != .code } + .onDisappear(perform: resignInputFocus) } } @@ -169,10 +173,18 @@ private extension ClassJoinView { } var bottomAction: some View { - Button { send(store.mode == .code ? .findTapped : .joinTapped) } label: { + Button { + resignInputFocus() + send(store.mode == .code ? .findTapped : .joinTapped) + } label: { Text(store.primaryButtonTitle) } .ctaButtonStyle(.primary, size: .large, height: 52) .disabled(store.mode == .code ? !store.canFindClass : !store.canJoin) } + + func resignInputFocus() { + isCodeFocused = false + isNicknameFocused = false + } } diff --git a/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift index 36cbfab4..c609f058 100644 --- a/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift +++ b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift @@ -44,7 +44,7 @@ public struct ClassTicketView: View { } .frame(maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) .screenBackground(.surfaceBeigeDefault) - .hidesSystemBars() + .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } } } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 613df669..d71446d9 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -439,7 +439,6 @@ platform :ios do begin # picke/PICKE 그룹에 배포한다. upload_to_testflight_with_retry( - api_key_path: ENV.fetch("APP_STORE_CONNECT_API_KEY_PATH"), ipa: ipa_path, changelog: testflight_changelog(new_build_number), groups: ["picke", "PICKE"], @@ -613,7 +612,6 @@ platform :ios do # skip_waiting_for_build_processing: false 로 App Store Connect 처리 완료까지 기다려야 # 심사 제출 단계에서 해당 빌드를 선택할 수 있다. upload_to_testflight_with_retry( - api_key_path: ENV.fetch("APP_STORE_CONNECT_API_KEY_PATH"), ipa: ipa_path, app_identifier: BUNDLE_ID, skip_submission: true,