diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift index 6f58ddbd..46986aae 100644 --- a/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinator.swift @@ -43,6 +43,7 @@ public struct ClassCoordinator { var participantJoinInstanceIDs: Set = [] var memberInstanceIDs: Set = [] var aiVoteInstanceIDs: Set = [] + var ticketInstanceIDs: Set = [] public init() { routes = [.root(.intro(.init()), embedInNavigationView: true)] @@ -61,7 +62,7 @@ public struct ClassCoordinator { && members.customAlert == nil && !members.isSaving case let .ownerDashboard(dashboard)?: return dashboard.management == nil - case .report?, .myOpinions?: + case .report?, .myOpinions?, .ticket?: return true default: return false @@ -378,14 +379,20 @@ private extension ClassCoordinator { guard case let .aiVote(vote) = route.screen else { return nil } return vote.instanceID }) + let ticketIDs = Set(state.routes.compactMap { route -> UUID? in + guard case let .ticket(ticket) = route.screen else { return nil } + return ticket.instanceID + }) let removedOwners = state.ownerNameInstanceIDs.subtracting(ownerIDs) let removedJoins = state.participantJoinInstanceIDs.subtracting(joinIDs) let removedMembers = state.memberInstanceIDs.subtracting(memberIDs) let removedVotes = state.aiVoteInstanceIDs.subtracting(voteIDs) + let removedTickets = state.ticketInstanceIDs.subtracting(ticketIDs) state.ownerNameInstanceIDs = ownerIDs state.participantJoinInstanceIDs = joinIDs state.memberInstanceIDs = memberIDs state.aiVoteInstanceIDs = voteIDs + state.ticketInstanceIDs = ticketIDs return .merge( removedOwners.map { .cancel(id: ClassJoinFeature.CancelID.nameOwner($0)) } + removedJoins.flatMap { id in @@ -401,6 +408,7 @@ private extension ClassCoordinator { .cancel(id: ClassAIVoteFeature.CancelID.submit(id)), ] } + + removedTickets.map { .cancel(id: ClassTicketFeature.CancelID.check($0)) } ) } @@ -463,6 +471,20 @@ private extension ClassCoordinator { state.routes.push(.join(.init(mode: .code, instanceID: uuid()))) return .none + case let .routeAction(routeID, action: .intro(.delegate(.ticket))): + guard routeID == 0, state.routes.count == 1, + case .intro = state.routes[0].screen + else { return .none } + invalidateCompletingJoin(state: &state) + state.routes.push(.ticket(.init(instanceID: uuid()))) + return .none + + case let .routeAction(routeID, action: .ticket(.delegate(.dismiss))): + guard routeID == state.routes.count - 1, + case .ticket = state.routes.last?.screen + else { return .none } + return .send(.view(.backAction)) + case let .routeAction(routeID, action: .join(.delegate(.found(room, instanceID: instanceID)))): guard routeID == state.routes.count - 1, let instanceID, @@ -1120,6 +1142,7 @@ extension ClassCoordinator { @Reducer public enum ClassScreen { case intro(ClassIntroFeature) + case ticket(ClassTicketFeature) case join(ClassJoinFeature) case startMethod(ClassStartMethodFeature) case topic(ClassTopicFeature) diff --git a/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift b/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift index 86f9c595..f348120c 100644 --- a/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift +++ b/Projects/App/Sources/Navigation/Class/ClassCoordinatorView.swift @@ -25,6 +25,9 @@ public struct ClassCoordinatorView: View { case let .intro(introStore): ClassIntroView(store: introStore) + case let .ticket(ticketStore): + ClassTicketView(store: ticketStore) + case let .join(joinStore): ClassJoinView(store: joinStore) diff --git a/Projects/App/Tests/Sources/ChatReplyNavigationTests.swift b/Projects/App/Tests/Sources/ChatReplyNavigationTests.swift new file mode 100644 index 00000000..57e79264 --- /dev/null +++ b/Projects/App/Tests/Sources/ChatReplyNavigationTests.swift @@ -0,0 +1,59 @@ +// +// ChatReplyNavigationTests.swift +// PickeTests +// +// Created by Picke on 10/19/26. +// + +import CommentDomainInterface +import ComposableArchitecture +import TCAFlow +import Testing + +@testable import Picke + +@MainActor +struct ChatReplyNavigationTests { + @Test + func 클래스_댓글에서_답글을_열고_뒤로가면_댓글로_돌아온다() async { + let parent = CommentItem( + perspectiveId: 42, + author: "작성자", + timeAgo: "방금 전", + option: .a, + content: "부모 의견", + replyCount: 1, + likeCount: 0, + createdOrder: 0 + ) + let store = TestStore(initialState: ChatCoordinator.State(commentBattleId: 7)) { + ChatCoordinator() + } + store.exhaustivity = .off + + await store.send(.router(.routeAction( + id: 0, + action: .comment(.delegate(.openReply(parent))) + ))) + #expect(store.state.routes.count == 2) + guard case let .commentReply(reply)? = store.state.routes.last?.screen else { + Issue.record("답글 화면으로 push되지 않음") + return + } + #expect(reply.perspectiveId == 42) + + await store.send(.router(.routeAction( + id: 1, + action: .commentReply(.delegate(.dismiss)) + ))) + await store.receive { + guard case .view(.backAction) = $0 else { return false } + return true + } + #expect(store.state.routes.count == 1) + guard case .comment = store.state.routes.last?.screen else { + Issue.record("댓글 화면으로 돌아오지 않음") + return + } + } +} diff --git a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift index 328f67aa..8b7f1577 100644 --- a/Projects/App/Tests/Sources/ClassCoordinatorTests.swift +++ b/Projects/App/Tests/Sources/ClassCoordinatorTests.swift @@ -1897,6 +1897,55 @@ struct ClassNavigationLoadingTests { @MainActor struct ClassParticipantPushTests { + @Test + func 인트로_이용권은_한번만_push하고_닫으면_돌아간다() async { + let ticketID = UUID(301) + let store = TestStore(initialState: ClassCoordinator.State()) { ClassCoordinator() } withDependencies: { + $0.uuid = .constant(ticketID) + } + + await store.send(.router(.routeAction(id: 0, action: .intro(.delegate(.ticket))))) { + $0.routes.push(.ticket(.init(instanceID: ticketID))) + $0.ticketInstanceIDs = [ticketID] + } + #expect(store.state.showsTabBar) + await store.send(.router(.routeAction(id: 0, action: .intro(.delegate(.ticket))))) + #expect(store.state.routes.count == 2) + + await store.send(.router(.routeAction(id: 1, action: .ticket(.delegate(.dismiss))))) + await store.receive(\.view.backAction) { + $0.routes.goBack() + $0.ticketInstanceIDs = [] + } + } + + @Test + func 이용권_확인_중_스와이프로_경로가_제거되면_응답도_취소된다() async { + let clock = TestClock() + let ticketID = UUID(302) + var ticket = ClassTicketFeature.State(instanceID: ticketID) + ticket.code = "PICKE30" + var state = ClassCoordinator.State() + state.routes.push(.ticket(ticket)) + state.ticketInstanceIDs = [ticketID] + let store = TestStore(initialState: state) { ClassCoordinator() } withDependencies: { + $0.continuousClock = clock + $0.date = .constant(Date(timeIntervalSince1970: 0)) + } + + await store.send(.router(.routeAction(id: 1, action: .ticket(.view(.checkTapped))))) { + guard case var .ticket(current) = $0.routes[1].screen else { return } + current.isChecking = true + $0.routes[screenAt: 1] = .ticket(current) + } + await store.send(.router(.updateRoutes(Array(state.routes.dropLast())))) { + $0.routes = Array($0.routes.dropLast()) + $0.ticketInstanceIDs = [] + } + await clock.advance(by: .milliseconds(350)) + await store.finish(timeout: .seconds(1)) + } + @Test func 인트로_참여는_코드_화면을_한번만_push한다() async { let codeID = UUID(201) diff --git a/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift b/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift new file mode 100644 index 00000000..a9ccbd97 --- /dev/null +++ b/Projects/App/Tests/Sources/ClassTicketVisualAuditTests.swift @@ -0,0 +1,67 @@ +import ComposableArchitecture +import DomainAssembly +import FeatureAssembly +import PickeCoreUtility +@testable import Picke +import SwiftUI +import TCAFlow +import UIKit +import XCTest + +final class ClassTicketVisualAuditTests: XCTestCase { + @MainActor + func testCaptureTicketEntryAndRegisteredScreens() async throws { + guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { + throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to capture ticket screens") + } + + let entry = ClassTicketFeature.State() + + var registered = ClassTicketFeature.State() + registered.phase = .registered + registered.registrationStartDate = try XCTUnwrap("2026.10.09".date(as: .yearMonthDayDotted)) + registered.expirationDate = try XCTUnwrap("2027.04.08".date(as: .yearMonthDayDotted)) + for (width, height) in [(402, 874), (375, 812)] { + let size = CGSize(width: width, height: height) + try await capture(entry, name: "ticket-entry-\(width)", size: size) + try await capture(registered, name: "ticket-registered-\(width)", size: size) + } + } + + @MainActor + private func capture( + _ ticket: ClassTicketFeature.State, + name: String, + size: CGSize + ) async throws { + var state = MainTabCoordinator.State(selectedTab: MainTabCoordinator.Tab.classroom.rawValue) + state.classState.routes.push(.ticket(ticket)) + let repository = MockClassRepository(rooms: [], availableRooms: []) + let store = Store(initialState: state) { + MainTabCoordinator() + .dependency(\.classUseCase, repository) + .dependency(\.classMockRepository, repository) + } + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.frame = CGRect(origin: .zero, size: size) + window.rootViewController = UIHostingController(rootView: MainTabView(store: store)) + window.makeKeyAndVisible() + defer { window.isHidden = true } + + try await Task.sleep(for: .milliseconds(500)) + window.layoutIfNeeded() + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let directory = try FileManager.default.url( + for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true + ).appendingPathComponent("ClassVisualAudit", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: 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/CommentFinalDesignTests.swift b/Projects/App/Tests/Sources/CommentFinalDesignTests.swift index c77b5e17..271bdc17 100644 --- a/Projects/App/Tests/Sources/CommentFinalDesignTests.swift +++ b/Projects/App/Tests/Sources/CommentFinalDesignTests.swift @@ -6,6 +6,7 @@ // import BattleDomainInterface +import CommentDomainInterface import ComposableArchitecture import FeatureAssembly @testable import Picke @@ -17,6 +18,62 @@ import XCTest /// Opt-in captures of the mounted global comment and reply screens for design review. final class CommentFinalDesignTests: XCTestCase { + @MainActor + func testReplyEditTakesInputFocus() async throws { + guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { + throw XCTSkip("Set PICKE_CLASS_VISUAL_AUDIT=1 to verify reply input focus") + } + + let session = ClassMockChatSession() + let battleID = await session.register(room: .mockJoinable) + let battleAPI: any BattleInterface = session + _ = try await battleAPI.submitPreVote(battleId: battleID, optionId: 1) + _ = try await battleAPI.submitPostVote(battleId: battleID, optionId: 1) + let createdPerspective = try await battleAPI.createPerspective( + battleId: battleID, + content: "부모 의견입니다.", + optionId: 1 + ) + let perspective = try XCTUnwrap(createdPerspective) + let perspectiveAPI: any PerspectiveInterface = session + _ = try await perspectiveAPI.createComment( + perspectiveId: perspective.perspectiveId, + content: "수정할 답글입니다." + ) + + let store = Store(initialState: CommentReplyFeature.State( + perspectiveId: perspective.perspectiveId, + parentComment: CommentItem(item: perspective, order: 0) + )) { + CommentReplyFeature() + .dependency(\.perspectiveUseCase, session) + .dependency(\.commentUseCase, session) + .dependency(\.analyticsUseCase, .testValue) + } + let window = try makeWindow(rootView: CommentReplyView(store: store)) + defer { window.isHidden = true } + try await waitFor("reply loaded for focus") { + !store.isLoadingDetail && !store.isLoadingReplies && !store.replies.isEmpty + } + + let reply = try XCTUnwrap(store.replies.first) + store.send(.view(.replyMenu(id: reply.id, action: .edit))) + try await waitFor("reply edit first responder") { hasFocusedInput(in: window) } + + window.endEditing(true) + try await waitFor("reply focus released") { !hasFocusedInput(in: window) } + store.send(.view(.parentMenu(.edit))) + try await waitFor("parent edit first responder") { hasFocusedInput(in: window) } + XCTAssertTrue(store.editingParent) + + window.endEditing(true) + try await waitFor("parent focus released") { !hasFocusedInput(in: window) } + store.send(.binding(.set(\CommentReplyFeature.State.editingParent, false))) + store.send(.view(.replyMenu(id: reply.id, action: .edit))) + try await waitFor("reply focus after parent edit") { hasFocusedInput(in: window) } + XCTAssertFalse(store.editingParent) + } + @MainActor func testGlobalCommentAndReplyCapture() async throws { guard ProcessInfo.processInfo.environment["PICKE_CLASS_VISUAL_AUDIT"] == "1" else { @@ -121,6 +178,7 @@ final class CommentFinalDesignTests: XCTestCase { replyStore.send(.view(.replyMenu(id: lastReply.id, action: .edit))) XCTAssertEqual(replyStore.editingCommentId, lastReply.commentId) XCTAssertEqual(replyStore.replyText, lastReply.content) + try await waitFor("reply edit focus") { hasFocusedInput(in: replyWindow) } try await capture(replyWindow, name: "reply-edit") replyStore.send(.view(.parentMenu(.more))) @@ -128,6 +186,18 @@ final class CommentFinalDesignTests: XCTestCase { scrollToTop(replyScroll) let parentMenuImage = try await capture(replyWindow, name: "reply-parent-menu") try assertMenuLabels(["수정", "삭제"], visibleIn: replyScroll, window: replyWindow, image: parentMenuImage) + replyWindow.endEditing(true) + try await waitFor("reply focus released before parent edit") { !hasFocusedInput(in: replyWindow) } + replyStore.send(.view(.parentMenu(.edit))) + try await waitFor("parent edit focus") { hasFocusedInput(in: replyWindow) } + XCTAssertTrue(replyStore.editingParent) + + replyWindow.endEditing(true) + try await waitFor("parent focus released before reply edit") { !hasFocusedInput(in: replyWindow) } + replyStore.send(.binding(.set(\CommentReplyFeature.State.editingParent, false))) + replyStore.send(.view(.replyMenu(id: lastReply.id, action: .edit))) + try await waitFor("reply focus after parent edit") { hasFocusedInput(in: replyWindow) } + XCTAssertFalse(replyStore.editingParent) } @MainActor @@ -185,6 +255,18 @@ final class CommentFinalDesignTests: XCTestCase { return window.rootViewController.map { containsInput($0.view) } ?? false } + @MainActor + private func hasFocusedInput(in window: UIWindow) -> Bool { + func containsFocusedInput(_ view: UIView) -> Bool { + if (view is UITextField || view is UITextView), view.window === window, view.isFirstResponder { + return true + } + return view.subviews.contains(where: containsFocusedInput) + } + window.layoutIfNeeded() + return window.rootViewController.map { containsFocusedInput($0.view) } ?? false + } + @MainActor private func verticalScrollView(in window: UIWindow) -> UIScrollView? { func all(in view: UIView) -> [UIScrollView] { diff --git a/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift index b25dd10b..ad2eb4b2 100644 --- a/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Feature/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -15,11 +15,22 @@ import PickeCoreUtility @ViewAction(for: CommentReplyFeature.self) public struct CommentReplyView: View { + private enum EditingTarget: Equatable { + case parent + case reply(Int) + } + @Bindable public var store: StoreOf @FocusState private var isReplyFocused: Bool /// 딥링크 타깃 답글로 1회만 스크롤. @State private var didScrollToTarget = false + private var editingTarget: EditingTarget? { + if store.editingParent { return .parent } + if let id = store.editingCommentId { return .reply(id) } + return nil + } + public init(store: StoreOf) { self.store = store } @@ -50,6 +61,9 @@ public struct CommentReplyView: View { .navigationBarHidden(true) .hidesSystemBars() .onAppear { send(.onAppear) } + .onChange(of: editingTarget) { _, target in + isReplyFocused = target != nil + } .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) .animation(.easeInOut(duration: 0.18), value: store.menuTargetReplyId) } @@ -82,7 +96,12 @@ private extension CommentReplyView { func replyMenu(for reply: CommentReplyItem) -> some View { VStack(alignment: .trailing, spacing: 8) { if reply.isMine { - PickeActionPill(title: "수정", systemImage: "pencil") { send(.replyMenu(id: reply.id, action: .edit)) } + PickeActionPill(title: "수정", systemImage: "pencil") { + guard reply.commentId != nil else { return } + store.editingParent = false + 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( @@ -98,7 +117,10 @@ private extension CommentReplyView { func parentMenuView() -> some View { VStack(alignment: .trailing, spacing: 8) { if store.parentComment.isMine { - PickeActionPill(title: "수정", systemImage: "pencil") { send(.parentMenu(.edit)) } + 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)) } diff --git a/Projects/Feature/Class/Sources/ClassAIChat/View/ClassAIChatView.swift b/Projects/Feature/Class/Sources/ClassAIChat/View/ClassAIChatView.swift index da490476..2902fa0e 100644 --- a/Projects/Feature/Class/Sources/ClassAIChat/View/ClassAIChatView.swift +++ b/Projects/Feature/Class/Sources/ClassAIChat/View/ClassAIChatView.swift @@ -238,7 +238,10 @@ private extension ClassAIChatView { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 6) - .pickeCard(isUser ? .beige400 : .cardBaseBackgroundDefault) + .pickeCard( + isUser ? .beige400 : .cardBaseBackgroundDefault, + insideBorder: isUser + ) } var bubbleLinePadding: CGFloat { diff --git a/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift b/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift index e31aac93..724c7498 100644 --- a/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift +++ b/Projects/Feature/Class/Sources/ClassIntro/Reducer/ClassIntroFeature.swift @@ -14,6 +14,8 @@ import PickeSharedUI public struct ClassIntroFeature { public init() {} + @Dependency(\.classTicketMockEnabled) private var classTicketMockEnabled + @ObservableState public struct State: Equatable { @Presents public var customAlert: CustomAlertState? @@ -98,12 +100,15 @@ extension ClassIntroFeature { return .send(.delegate(.create)) case .ticketTapped: - state.customAlert = .alert( - title: "이용권 등록 준비 중", - message: "이용권 등록 기능은 아직 사용할 수 없습니다.", - cancelTitle: "" - ) - return .none + guard classTicketMockEnabled else { + state.customAlert = .alert( + title: "이용권 등록 준비 중", + message: "이용권 등록 기능은 아직 사용할 수 없습니다.", + cancelTitle: "" + ) + return .none + } + return .send(.delegate(.ticket)) } } } diff --git a/Projects/Feature/Class/Sources/ClassStartMethod/View/ClassStartMethodView.swift b/Projects/Feature/Class/Sources/ClassStartMethod/View/ClassStartMethodView.swift index 715ade45..977f5980 100644 --- a/Projects/Feature/Class/Sources/ClassStartMethod/View/ClassStartMethodView.swift +++ b/Projects/Feature/Class/Sources/ClassStartMethod/View/ClassStartMethodView.swift @@ -20,43 +20,8 @@ public struct ClassStartMethodView: View { public var body: some View { VStack(spacing: 0) { - PickeNavigationBar( - onBack: { send(.backTapped) }, - centerTitle: "새 클래스 만들기" - ) - .foregroundStyle(.gray500) - - VStack(alignment: .leading, spacing: 0) { - titleSection() - .padding(.bottom, 32) - - VStack(spacing: 16) { - methodCard( - .existingContent, - guide: .pickeBattle, - badge: "TTS 포함", - title: "Pické 배틀로 클래스 만들기", - description: "기존 배틀 콘텐츠를 골라 클래스를 만들어요" - ) - methodCard( - .ownTopic, - guide: .customBattle, - badge: "AI 채팅", - title: "커스텀 배틀로 클래스 만들기", - description: "원하는 주제로 직접 배틀을 만들어보세요" - ) - } - - Spacer(minLength: 16) - - Button { send(.continueTapped) } label: { - Text("주제 정하기") - } - .ctaButtonStyle(.primary, size: .large, height: 52) - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 16) + navigationBar + contentSection() } .screenBackground() .toolbar(.hidden, for: .navigationBar) @@ -72,6 +37,58 @@ public struct ClassStartMethodView: View { } private extension ClassStartMethodView { + var navigationBar: some View { + PickeNavigationBar( + onBack: { send(.backTapped) }, + centerTitle: "새 클래스 만들기" + ) + .foregroundStyle(.gray500) + } + + @ViewBuilder + func contentSection() -> some View { + VStack(alignment: .leading, spacing: 0) { + titleSection() + .padding(.bottom, 32) + + methodCards() + + Spacer(minLength: 16) + + continueButton + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 16) + } + + @ViewBuilder + func methodCards() -> some View { + VStack(spacing: 16) { + methodCard( + .existingContent, + guide: .pickeBattle, + badge: "TTS 포함", + title: "Pické 배틀로 클래스 만들기", + description: "기존 배틀 콘텐츠를 골라 클래스를 만들어요" + ) + methodCard( + .ownTopic, + guide: .customBattle, + badge: "AI 채팅", + title: "커스텀 배틀로 클래스 만들기", + description: "원하는 주제로 직접 배틀을 만들어보세요" + ) + } + } + + var continueButton: some View { + Button { send(.continueTapped) } label: { + Text("주제 정하기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + } + @ViewBuilder func titleSection() -> some View { VStack(alignment: .leading, spacing: 6) { diff --git a/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketCheckError.swift b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketCheckError.swift new file mode 100644 index 00000000..2906358b --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketCheckError.swift @@ -0,0 +1,5 @@ +import Foundation + +public enum ClassTicketCheckError: Error, Equatable, Sendable { + case invalidCode +} diff --git a/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketMockAvailability.swift b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketMockAvailability.swift new file mode 100644 index 00000000..f1109250 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketMockAvailability.swift @@ -0,0 +1,19 @@ +import ComposableArchitecture +import Foundation + +public enum ClassTicketMockAvailability: DependencyKey { + #if DEBUG + public static let liveValue = ProcessInfo.processInfo.environment["PICKE_CLASS_TICKET_MOCK"] == "1" + #else + public static let liveValue = false + #endif + + public static let testValue = false +} + +public extension DependencyValues { + var classTicketMockEnabled: Bool { + get { self[ClassTicketMockAvailability.self] } + set { self[ClassTicketMockAvailability.self] = newValue } + } +} diff --git a/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketPhase.swift b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketPhase.swift new file mode 100644 index 00000000..4fe38a48 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassTicket/Model/ClassTicketPhase.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum ClassTicketPhase: Equatable, Sendable { + case entry + case registered +} diff --git a/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift new file mode 100644 index 00000000..27bf8abf --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassTicket/Reducer/ClassTicketFeature.swift @@ -0,0 +1,125 @@ +// +// ClassTicketFeature.swift +// Class +// + +import ComposableArchitecture +import Foundation +import PickeCoreUtility + +@Reducer +public struct ClassTicketFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var instanceID = UUID() + public var phase: ClassTicketPhase = .entry + public var code = "" + public var isChecking = false + public var errorMessage: String? + public var expandedFAQ: Int? + public var registrationStartDate: Date? + public var expirationDate: Date? + + public init(instanceID: UUID = UUID()) { + self.instanceID = instanceID + } + + public var canCheckCode: Bool { + !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isChecking + } + + public var periodText: String { + guard let registrationStartDate, let expirationDate else { return "" } + return "\(registrationStartDate.formatted(.yearMonthDayDotted)) – \(expirationDate.formatted(.yearMonthDayDotted))" + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case checkResponse(Result) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backTapped + case checkTapped + case confirmTapped + case faqTapped(Int) + } + + @CasePathable + public enum DelegateAction: Equatable { + case dismiss + } + + public enum CancelID: Hashable, Sendable { + case check(UUID) + } + + @Dependency(\.continuousClock) private var clock + @Dependency(\.date) private var date + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.code): + state.errorMessage = nil + guard state.isChecking else { return .none } + state.isChecking = false + return .cancel(id: CancelID.check(state.instanceID)) + + case .binding: + return .none + + case .view(.backTapped), .view(.confirmTapped): + return .send(.delegate(.dismiss)) + + case let .view(.faqTapped(index)): + state.expandedFAQ = state.expandedFAQ == index ? nil : index + return .none + + 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) + return .run { [clock, date] send in + try await clock.sleep(for: .milliseconds(350)) + let result: Result = submittedCode == "PICKE30" + ? .success(date.now) + : .failure(.invalidCode) + await send(.checkResponse(result)) + } + .cancellable(id: CancelID.check(state.instanceID), cancelInFlight: true) + + case let .checkResponse(result): + guard state.phase == .entry, state.isChecking else { return .none } + state.isChecking = false + switch result { + case let .success(registeredAt): + let calendar = AppDateFormat.calendar + let start = calendar.startOfDay(for: registeredAt) + guard + let sixMonthsLater = calendar.date(byAdding: .month, value: 6, to: start), + let lastDay = calendar.date(byAdding: .day, value: -1, to: sixMonthsLater) + else { return .none } + state.registrationStartDate = start + state.expirationDate = lastDay + state.phase = .registered + + case .failure: + state.errorMessage = "유효하지 않은 이용권 코드예요. 다시 확인해 주세요." + } + return .none + + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift new file mode 100644 index 00000000..2f887533 --- /dev/null +++ b/Projects/Feature/Class/Sources/ClassTicket/View/ClassTicketView.swift @@ -0,0 +1,266 @@ +// +// ClassTicketView.swift +// Class +// + +import ComposableArchitecture +import PickeDesignKit +import SwiftUI + +@ViewAction(for: ClassTicketFeature.self) +public struct ClassTicketView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar( + onBack: { send(.backTapped) }, + centerTitle: "이용권 등록하기" + ) + .foregroundStyle(.gray800) + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if store.phase == .entry { + entryContent() + } else { + registeredContent() + } + } + .frame(maxWidth: .infinity) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + .frame(maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .screenBackground(.surfaceBeigeDefault) + .hidesSystemBars() + } +} + +private extension ClassTicketView { + @ViewBuilder + func entryContent() -> some View { + VStack(alignment: .leading, spacing: 0) { + entryHeading() + .padding(.bottom, 36) + codeSection() + .padding(.bottom, 24) + faqSection() + bottomSection() + .padding(.top, 40) + } + .padding(.horizontal, 16) + .padding(.top, 20) + } + + @ViewBuilder + func entryHeading() -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("클래스 이용권을 등록하세요") + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + + Text("이메일을 통해 리워드로 받은 코드를 입력해 주세요\n등록 전에 이용권 내용을 확인할 수 있어요") + .pretendardFont(.bodyMedium) + .lineSpacing(4) + .foregroundStyle(.gray300) + } + } + + @ViewBuilder + func codeSection() -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 3) { + Text("이용권 코드") + .foregroundStyle(.gray800) + Text("*") + .foregroundStyle(.errorDefault) + } + .pretendardFont(.labelSmall) + + TextField("", text: $store.code, prompt: Text("전달 받은 코드를 입력해주세요").foregroundStyle(.gray300)) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .pickeTextField(height: 44) + .accessibilityIdentifier("class-ticket-code") + + if let errorMessage = store.errorMessage { + Text(errorMessage) + .pretendardFont(.bodySmall) + .foregroundStyle(.errorDefault) + .accessibilityIdentifier("class-ticket-error") + } + } + } + + @ViewBuilder + func faqSection() -> some View { + VStack(alignment: .leading, spacing: 0) { + faqRow( + index: 0, + question: "이용권 코드는 어디서 확인하나요?", + answer: "리워드로 받은 이메일에서 이용권 코드를 확인할 수 있어요." + ) + faqRow( + index: 1, + question: "클래스 참여 코드와 다른가요?", + answer: "이용권 코드는 클래스를 개설할 때 사용해요. 참여 코드는 다른 사람의 클래스에 들어갈 때 사용해요." + ) + } + } + + @ViewBuilder + func faqRow( + index: Int, + question: String, + answer: String + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + send(.faqTapped(index)) + } label: { + HStack(spacing: 8) { + Text(question) + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) + Image(systemName: "chevron.down") + .pretendardFont(.medium11) + .foregroundStyle(.gray300) + .rotationEffect(.degrees(store.expandedFAQ == index ? 180 : 0)) + } + .frame(maxWidth: .infinity, minHeight: 32, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if store.expandedFAQ == index { + Text(answer) + .pretendardFont(.bodySmall) + .foregroundStyle(.gray500) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + .padding(.bottom, 12) + } + } + .accessibilityIdentifier("class-ticket-faq-\(index)") + } + + @ViewBuilder + func registeredContent() -> some View { + VStack(spacing: 0) { + registeredHeading() + .padding(.bottom, 38) + ticketCard() + bottomSection() + .padding(.top, 40) + } + .padding(.horizontal, 16) + .padding(.top, 10) + } + + @ViewBuilder + func registeredHeading() -> some View { + VStack(spacing: 8) { + Text("이용권이 등록됐어요") + .pretendardFont(.semiBold24) + .foregroundStyle(.gray800) + + Text("이제 우리만의 클래스를 열고\n사람들을 초대해보세요") + .pretendardFont(.bodyMedium) + .lineSpacing(4) + .multilineTextAlignment(.center) + .foregroundStyle(.gray300) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + func ticketCard() -> some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(asset: .tabClassActive) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text("클래스 30인 이용권") + .pretendardFont(family: .SemiBold, size: 18) + .foregroundStyle(.gray800) + } + Text("함께 생각을 나누는 모임을 시작하세요") + .pretendardFont(.bodySmall) + .foregroundStyle(.primary500) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(.beige400) + + VStack(spacing: 16) { + ticketDetail(label: "참여 인원", value: "클래스당 최대 30명") + ticketDetail(label: "이용 기간", value: store.periodText) + ticketDetail(label: "클래스 생성", value: "이용 기간 내 무제한") + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .background(.surfaceBeigeDefault) + .clipShape(RoundedRectangle(cornerRadius: 2)) + .overlay { + RoundedRectangle(cornerRadius: 2) + .strokeBorder(.borderGraySubtle, lineWidth: 1) + } + .accessibilityIdentifier("class-ticket-card") + } + + @ViewBuilder + func ticketDetail( + label: String, + value: String + ) -> some View { + HStack(spacing: 8) { + Text(label) + .foregroundStyle(.gray300) + Spacer(minLength: 0) + Text(value) + .foregroundStyle(.gray500) + } + .pretendardFont(.bodySmall) + } + + @ViewBuilder + func bottomSection() -> some View { + VStack(spacing: 10) { + if store.phase == .registered { + Text("체험용 등록이며 마이페이지에 저장되지 않아요") + .pretendardFont(.bodySmall) + .foregroundStyle(.gray300) + } + + if store.phase == .entry && !store.canCheckCode { + Button(store.isChecking ? "확인 중..." : "코드 확인하기") {} + .pretendardFont(.headingMedium) + .foregroundStyle(.gray300) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(.gray100, in: RoundedRectangle(cornerRadius: 2)) + .buttonStyle(.plain) + .disabled(true) + .accessibilityIdentifier("class-ticket-primary") + } else { + Button { + send(store.phase == .entry ? .checkTapped : .confirmTapped) + } label: { + Text(store.phase == .entry ? "코드 확인하기" : "이용권 확인하기") + } + .ctaButtonStyle(.primary, size: .large, height: 52) + .accessibilityIdentifier("class-ticket-primary") + } + } + } +} diff --git a/Projects/Feature/Class/Tests/Sources/ClassTests.swift b/Projects/Feature/Class/Tests/Sources/ClassTests.swift index 85ca5226..5cce5f8f 100644 --- a/Projects/Feature/Class/Tests/Sources/ClassTests.swift +++ b/Projects/Feature/Class/Tests/Sources/ClassTests.swift @@ -53,9 +53,23 @@ struct ClassTests { } @Test - func 이용권_등록_기능이_없으면_안내를_표시한다() async { + func Mock_이용권을_활성화하면_등록_화면으로_이동한다() async { let store = TestStore(initialState: ClassIntroFeature.State()) { ClassIntroFeature() + } withDependencies: { + $0.classTicketMockEnabled = true + } + + await store.send(.view(.ticketTapped)) + await store.receive(\.delegate.ticket) + } + + @Test + func Mock_이용권이_꺼져_있으면_기존_안내를_보여준다() async { + let store = TestStore(initialState: ClassIntroFeature.State()) { + ClassIntroFeature() + } withDependencies: { + $0.classTicketMockEnabled = false } await store.send(.view(.ticketTapped)) { diff --git a/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift b/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift new file mode 100644 index 00000000..cdc35a9d --- /dev/null +++ b/Projects/Feature/Class/Tests/Sources/ClassTicketTests.swift @@ -0,0 +1,139 @@ +// +// ClassTicketTests.swift +// ClassTests +// + +@testable import Class +import ComposableArchitecture +import Foundation +import PickeCoreUtility +import Testing + +@MainActor +struct ClassTicketTests { + @Test + func 유효한_Mock_코드는_확인_뒤_등록되고_서울_날짜를_표시한다() async throws { + let clock = TestClock() + let registeredAt = try #require("2026.10.09".date(as: .yearMonthDayDotted)) + var state = ClassTicketFeature.State() + state.code = "PICKE30" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.continuousClock = clock + $0.date = .constant(registeredAt) + } + + await store.send(.view(.checkTapped)) { + $0.isChecking = true + } + await clock.advance(by: .milliseconds(350)) + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.phase = .registered + $0.registrationStartDate = registeredAt + $0.expirationDate = "2027.04.08".date(as: .yearMonthDayDotted) + } + #expect(store.state.periodText == "2026.10.09 – 2027.04.08") + } + + @Test + func 유효하지_않은_코드는_입력_상태를_유지한다() async { + let clock = TestClock() + var state = ClassTicketFeature.State() + state.code = "WRONG" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.continuousClock = clock + $0.date = .constant(Date(timeIntervalSince1970: 0)) + } + + await store.send(.view(.checkTapped)) { + $0.isChecking = true + } + await clock.advance(by: .milliseconds(350)) + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.errorMessage = "유효하지 않은 이용권 코드예요. 다시 확인해 주세요." + } + #expect(store.state.phase == .entry) + } + + @Test + func 확인_중_코드를_바꾸면_이전_응답을_취소한다() async { + let clock = TestClock() + var state = ClassTicketFeature.State() + state.code = "PICKE30" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.continuousClock = clock + $0.date = .constant(Date(timeIntervalSince1970: 0)) + } + + await store.send(.view(.checkTapped)) { + $0.isChecking = true + } + await store.send(.binding(.set(\.code, "PICKE3"))) { + $0.code = "PICKE3" + $0.isChecking = false + } + await clock.advance(by: .milliseconds(350)) + await store.finish(timeout: .seconds(1)) + #expect(store.state.phase == .entry) + } + + @Test + func 월말_등록은_서울_달력의_월_보정_정책을_따른다() async throws { + for (start, end) in [ + ("2026.08.31", "2027.02.27"), + ("2027.08.31", "2028.02.28"), + ] { + let clock = TestClock() + let registeredAt = try #require(start.date(as: .yearMonthDayDotted)) + var state = ClassTicketFeature.State() + state.code = "PICKE30" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.continuousClock = clock + $0.date = .constant(registeredAt) + } + + await store.send(.view(.checkTapped)) { + $0.isChecking = true + } + await clock.advance(by: .milliseconds(350)) + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.phase = .registered + $0.registrationStartDate = registeredAt + $0.expirationDate = end.date(as: .yearMonthDayDotted) + } + #expect(store.state.periodText == "\(start) – \(end)") + } + } + + @Test + func 밤_열한시_오십구분에_등록해도_서울_기준_당일부터_계산한다() async throws { + let clock = TestClock() + let registeredAt = try #require(AppDateFormat.calendar.date(from: DateComponents( + year: 2026, + month: 10, + day: 18, + hour: 23, + minute: 59 + ))) + var state = ClassTicketFeature.State() + state.code = "PICKE30" + let store = TestStore(initialState: state) { ClassTicketFeature() } withDependencies: { + $0.continuousClock = clock + $0.date = .constant(registeredAt) + } + + await store.send(.view(.checkTapped)) { + $0.isChecking = true + } + await clock.advance(by: .milliseconds(350)) + await store.receive(\.checkResponse) { + $0.isChecking = false + $0.phase = .registered + $0.registrationStartDate = "2026.10.18".date(as: .yearMonthDayDotted) + $0.expirationDate = "2027.04.17".date(as: .yearMonthDayDotted) + } + #expect(store.state.periodText == "2026.10.18 – 2027.04.17") + } +} diff --git a/Projects/UI/PickeDesignKit/Sources/Extension/View/View+Border.swift b/Projects/UI/PickeDesignKit/Sources/Extension/View/View+Border.swift index 4fbf5989..94dbc955 100644 --- a/Projects/UI/PickeDesignKit/Sources/Extension/View/View+Border.swift +++ b/Projects/UI/PickeDesignKit/Sources/Extension/View/View+Border.swift @@ -8,13 +8,22 @@ import SwiftUI public extension View { - /// `.overlay(RoundedRectangle(cornerRadius:).stroke(color, lineWidth:))` 축약. + /// 라운드 테두리. `insideBorder`는 외곽선을 도형 내부에 그린다. func roundedBorder( _ color: Color, lineWidth: CGFloat = 1, - radius: CGFloat = .radiusDefault + radius: CGFloat = .radiusDefault, + insideBorder: Bool = false ) -> some View { - overlay(RoundedRectangle(cornerRadius: radius).stroke(color, lineWidth: lineWidth)) + overlay { + if insideBorder { + RoundedRectangle(cornerRadius: radius) + .strokeBorder(color, lineWidth: lineWidth) + } else { + RoundedRectangle(cornerRadius: radius) + .stroke(color, lineWidth: lineWidth) + } + } } /// 카드 외형 — 라운드 배경 + 테두리. @@ -22,13 +31,15 @@ public extension View { _ background: Color = .cardBaseBackgroundDefault, border: Color = .borderBeigeDefault, lineWidth: CGFloat = 1, - radius: CGFloat = .radiusDefault + radius: CGFloat = .radiusDefault, + insideBorder: Bool = false ) -> some View { roundedBackground(background, radius: radius) .roundedBorder( border, lineWidth: lineWidth, - radius: radius + radius: radius, + insideBorder: insideBorder ) }