From b37288af9fd845312387ac79fc35af832ee7b2f4 Mon Sep 17 00:00:00 2001 From: cchanmi Date: Fri, 21 Aug 2026 00:25:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[FIX]=20=ED=99=88=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EC=8B=9C=20=EB=8B=89=EB=84=A4=EC=9E=84=20=ED=94=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=ED=99=80=EB=8D=94=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 콜드 스타트 후 홈 진입 시 닉네임이 "OO" 플레이스홀더에서 실제 값으로 바뀌는 게 보이는 문제를 해결. - greeting은 항상 마운트해 레이아웃(자리)을 고정하고, 프로필이 준비되기 전엔 opacity 0으로만 감춤 — MessageComposerView가 밀리며 움직이지 않음 - 닉네임 텍스트가 확정되는 트랜잭션과 opacity 애니메이션 트랜잭션을 분리(isGreetingReady + onChange)해서, 텍스트 폭이 바뀌며 스냅되는 것 없이 화면이 제자리에서 크로스 디졸브로 나타남 - MessageComposerView는 프로필 조회 성공/로딩/실패 여부와 무관하게 항상 렌더링 — 조회 실패 시에도 메시지 입력이 막히지 않음 --- GAMSS/Sources/Presentation/Home/HomeView.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/GAMSS/Sources/Presentation/Home/HomeView.swift b/GAMSS/Sources/Presentation/Home/HomeView.swift index 08e334e..1f40e8c 100644 --- a/GAMSS/Sources/Presentation/Home/HomeView.swift +++ b/GAMSS/Sources/Presentation/Home/HomeView.swift @@ -13,6 +13,7 @@ struct HomeView: View { @State private var isSettingPresented = false @SwiftUI.Environment(UserManager.self) private var userManager @State private var loginSession = LoginSession() + @State private var isGreetingReady = false init(viewModel: HomeViewModel) { _viewModel = StateObject(wrappedValue: viewModel) @@ -52,6 +53,7 @@ struct HomeView: View { greeting .padding(.bottom, Spacing.spacing500) + .opacity(isGreetingReady ? 1 : 0) MessageComposerView( input: $viewModel.input, @@ -95,7 +97,16 @@ struct HomeView: View { .navigationDestination(isPresented: $isSettingPresented) { SettingView().toolbar(.hidden, for: .tabBar) } - .task { await viewModel.loadProfileIfNeeded() } + .task { + if userManager.user != nil { isGreetingReady = true } + await viewModel.loadProfileIfNeeded() + } + .onChange(of: userManager.user != nil) { _, isReady in + guard isReady, !isGreetingReady else { return } + withAnimation(.easeInOut(duration: 0.3)) { + isGreetingReady = true + } + } .alert(viewModel.alertMessage ?? "", isPresented: Binding( get: { viewModel.alertMessage != nil }, set: { if !$0 { viewModel.alertMessage = nil } } From 606e1f19eed041dd26d09bf9320d7b3f3f166251 Mon Sep 17 00:00:00 2001 From: cchanmi Date: Fri, 21 Aug 2026 00:54:33 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[FIX]=20=EB=8B=B5=EC=9E=A5=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EC=8B=9C=20=EA=B0=90=EC=A0=95=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=9D=91=EB=8B=B5=EC=9D=84=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=8B=B5=EC=9E=A5=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EA=B0=80=20=EB=B0=94=EB=A1=9C=20=EA=BA=BC?= =?UTF-8?q?=EC=A7=80=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replyTarget이 sendMessageUseCase.execute() 완료(캐릭터 답장 생성까지 포함) 이후에만 nil로 바뀌어서, 답장 미리보기/취소 버튼이 캐릭터 응답이 올 때까지 화면에 남아있던 문제. input/pendingUserMessage와 동일하게 전송 시작 시점에 즉시 비우고, 실패 시엔 그 사이에 사용자가 새 답장을 고르지 않았을 때만 복원하도록 함(input 복원과 동일한 가드). --- .../Presentation/Chat/ChatViewModel.swift | 6 +-- .../Presentation/ChatViewModelTests.swift | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift b/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift index eaaa8c4..cffa30d 100644 --- a/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift +++ b/GAMSS/Sources/Presentation/Chat/ChatViewModel.swift @@ -203,6 +203,7 @@ final class ChatViewModel: ObservableObject { flushPendingComments() let replyTarget = replyTarget + self.replyTarget = nil pendingUserMessage = PendingUserMessage( content: trimmed, sentAt: Date(), @@ -222,9 +223,6 @@ final class ChatViewModel: ObservableObject { excludedCharacters: excludedCharacters ) pendingUserMessage = nil - if self.replyTarget == replyTarget { - self.replyTarget = nil - } seed(with: sent) // 요약기(온디바이스 추론)가 끝날 때까지 다음 입력을 막지 않도록 백그라운드로 돌린다. @@ -233,10 +231,12 @@ final class ChatViewModel: ObservableObject { pendingSummaryUpdateTask = Task { await summaryStore.add(trimmed) } } catch let error as SendMessageValidationError { pendingUserMessage = nil + if self.replyTarget == nil { self.replyTarget = replyTarget } if input.isEmpty { input = trimmed } alertMessage = error.errorDescription } catch { pendingUserMessage = nil + if self.replyTarget == nil { self.replyTarget = replyTarget } if input.isEmpty { input = trimmed } alertMessage = "메시지를 보내지 못했어요" } diff --git a/GAMSSTests/Presentation/ChatViewModelTests.swift b/GAMSSTests/Presentation/ChatViewModelTests.swift index 1fc10eb..06e7e22 100644 --- a/GAMSSTests/Presentation/ChatViewModelTests.swift +++ b/GAMSSTests/Presentation/ChatViewModelTests.swift @@ -425,6 +425,52 @@ final class ChatViewModelTests: XCTestCase { XCTAssertEqual(viewModel.replyTarget, newTarget, "전송 성공 처리가 그 사이에 새로 고른 답장 대상을 지우면 안 됨") } + func test_send_withReplyTarget_clearsReplyTargetImmediatelyBeforeNetworkResponds() async { + let repository = MockConversationRepository() + let gate = SendGate() + repository.sendGate = gate + repository.stubbedSendResult = .success(SentMessage( + message: Message(id: 2, conversationId: 10, sender: .user, content: "고마워", repliesToMessageId: 5, createdAt: Date(timeIntervalSince1970: 0)), + commentStatus: .done, comments: [] + )) + let viewModel = makeViewModel(repository: repository) + viewModel.startReply(to: Message(id: 5, conversationId: 10, sender: .character(.anxiety), content: "안녕하세용", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0))) + viewModel.input = "고마워" + + let sendTask = Task { await viewModel.send() } + while viewModel.pendingUserMessage == nil { + await Task.yield() + } + + XCTAssertNil(viewModel.replyTarget, "감정 캐릭터의 응답을 기다리지 않고 답장 모드가 바로 꺼져야 함") + + await gate.open() + await sendTask.value + } + + func test_send_onFailure_doesNotRestoreReplyTargetIfUserStartedNewReplyMidFlight() async { + let repository = MockConversationRepository() + let gate = SendGate() + repository.sendGate = gate + repository.stubbedSendResult = .failure(SummaryError.inferenceFailed()) + let viewModel = makeViewModel(repository: repository) + viewModel.startReply(to: Message(id: 5, conversationId: 10, sender: .character(.anxiety), content: "안녕하세용", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0))) + viewModel.input = "고마워" + + let sendTask = Task { await viewModel.send() } + while viewModel.pendingUserMessage == nil { + await Task.yield() + } + + let newTarget = Message(id: 6, conversationId: 10, sender: .character(.joy), content: "반가워", repliesToMessageId: nil, createdAt: Date(timeIntervalSince1970: 0)) + viewModel.startReply(to: newTarget) + + await gate.open() + await sendTask.value + + XCTAssertEqual(viewModel.replyTarget, newTarget, "전송 실패 복원이 그 사이에 새로 고른 답장 대상을 덮어쓰면 안 됨") + } + func test_send_withoutReplyTarget_pendingUserMessageHasNoQuote() async { let repository = MockConversationRepository() let gate = SendGate() From 495c1e7dfe8052bc285d006ebced47d38b1a19f5 Mon Sep 17 00:00:00 2001 From: cchanmi Date: Fri, 21 Aug 2026 01:10:29 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[FEAT]=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EB=8F=84=20=ED=86=A0=ED=81=B0=20=EC=86=8C=EC=A7=84=20=EC=8B=9C?= =?UTF-8?q?=20=EC=B1=84=ED=8C=85=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EC=9E=85=EB=A0=A5=EC=9D=84=20=EB=A7=89=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지 ChatView는 토큰을 다 쓰면 입력창을 막고 안내 문구를 보여줬지만 HomeView는 그대로 입력이 가능했다. HomeViewModel에 GetTokenUsageUseCase를 추가해 화면 진입/재진입마다(채팅에서 토큰을 다 쓰고 돌아왔을 수 있어 캐싱하지 않음) 토큰 사용량을 확인하고, 다 썼으면 MessageComposerView에 동일 문구("오늘의 토큰을 모두 사용했어요")를 플레이스홀더로 보여주며 입력/감정선택/전송을 막는다. send()에도 가드를 추가해 이중 방어한다. --- .../Sources/Presentation/Home/HomeView.swift | 11 +++- .../Presentation/Home/HomeViewModel.swift | 22 ++++++- .../Home/MessageComposerView.swift | 31 ++++++++-- .../Presentation/Root/MainTabView.swift | 3 + .../Presentation/HomeViewModelTests.swift | 62 +++++++++++++++++++ 5 files changed, 121 insertions(+), 8 deletions(-) diff --git a/GAMSS/Sources/Presentation/Home/HomeView.swift b/GAMSS/Sources/Presentation/Home/HomeView.swift index 1f40e8c..ee23a3e 100644 --- a/GAMSS/Sources/Presentation/Home/HomeView.swift +++ b/GAMSS/Sources/Presentation/Home/HomeView.swift @@ -63,7 +63,9 @@ struct HomeView: View { onToggleEmotion: { viewModel.toggleEmotion($0) }, onCommit: { viewModel.send() }, onInputChange: { viewModel.updateInput($0) }, - isFocused: $isInputFocused + isFocused: $isInputFocused, + isDisabled: viewModel.isTokenExceeded, + disabledPlaceholder: viewModel.composerDisabledPlaceholder ) Spacer() @@ -99,7 +101,9 @@ struct HomeView: View { } .task { if userManager.user != nil { isGreetingReady = true } - await viewModel.loadProfileIfNeeded() + async let profile: Void = viewModel.loadProfileIfNeeded() + async let tokenUsage: Void = viewModel.loadTokenUsage() + _ = await (profile, tokenUsage) } .onChange(of: userManager.user != nil) { _, isReady in guard isReady, !isGreetingReady else { return } @@ -199,6 +203,9 @@ struct HomeView: View { viewModel: HomeViewModel( fetchMyProfileUseCase: DefaultFetchMyProfileUseCase( memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared) + ), + getTokenUsageUseCase: GetTokenUsageUseCase( + memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared) ) ) ) diff --git a/GAMSS/Sources/Presentation/Home/HomeViewModel.swift b/GAMSS/Sources/Presentation/Home/HomeViewModel.swift index 4d09e4b..e5ae6d3 100644 --- a/GAMSS/Sources/Presentation/Home/HomeViewModel.swift +++ b/GAMSS/Sources/Presentation/Home/HomeViewModel.swift @@ -15,15 +15,21 @@ final class HomeViewModel: ObservableObject { @Published var pendingFirstMessage: PendingFirstMessage? @Published private(set) var selectedEmotions: Set = Set(EmotionCharacter.allCases) @Published var isEmotionPickerOpen = false + @Published private(set) var isTokenExceeded = false + + let composerDisabledPlaceholder = "오늘의 토큰을 모두 사용했어요" private let fetchMyProfileUseCase: FetchMyProfileUseCase + private let getTokenUsageUseCase: GetTokenUsageUseCase private let userManager: UserManager init( fetchMyProfileUseCase: FetchMyProfileUseCase, + getTokenUsageUseCase: GetTokenUsageUseCase, userManager: UserManager = .shared ) { self.fetchMyProfileUseCase = fetchMyProfileUseCase + self.getTokenUsageUseCase = getTokenUsageUseCase self.userManager = userManager } @@ -46,7 +52,19 @@ final class HomeViewModel: ObservableObject { } var isSendDisabled: Bool { - input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || selectedEmotions.isEmpty + input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || selectedEmotions.isEmpty || isTokenExceeded + } + + /// 화면 진입/재진입마다 호출한다. 채팅에서 토큰을 다 쓰고 돌아왔을 수 있어, 프로필과 + /// 달리 캐싱하지 않고 매번 최신 상태를 확인한다. + func loadTokenUsage() async { + do { + let usage = try await getTokenUsageUseCase.execute() + isTokenExceeded = usage.exceeded + } catch { + // 실패해도 조용히 무시한다 — 입력을 막을지 여부만 결정하는 부가 정보라, 홈 진입 + // 자체를 방해하는 얼럿까지는 띄우지 않는다. + } } /// 감정 선택을 토글한다. 전체 해제(0개)도 허용한다 — 그 경우 `isSendDisabled`가 true가 @@ -63,7 +81,7 @@ final class HomeViewModel: ObservableObject { /// ChatViewModel이 화면 진입 직후 pendingFirstMessage로 자동 시작한다. func send() { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard !trimmed.isEmpty, !isTokenExceeded else { return } guard trimmed.count <= ConversationSummaryPolicy.maxMessageLength else { alertMessage = SendMessageValidationError.tooLong.errorDescription return diff --git a/GAMSS/Sources/Presentation/Home/MessageComposerView.swift b/GAMSS/Sources/Presentation/Home/MessageComposerView.swift index 5f95439..d9dd8b7 100644 --- a/GAMSS/Sources/Presentation/Home/MessageComposerView.swift +++ b/GAMSS/Sources/Presentation/Home/MessageComposerView.swift @@ -28,6 +28,9 @@ struct MessageComposerView: View { /// 포커스 상태는 상위(HomeView)가 소유한다 — 빈 화면 탭으로 키보드를 내리는 처리가 상위에 /// 있어, 이 뷰가 자체 `@FocusState`를 따로 가지면 상위에서 그 상태를 제어할 수 없다. var isFocused: FocusState.Binding + /// 오늘의 토큰을 모두 썼으면 true — 입력 자체를 막고 감정/전송 컨트롤도 숨긴다. + let isDisabled: Bool + let disabledPlaceholder: String /// 박스 하단 컨트롤 행(감정 트리거/전송 버튼)이 차지하는 높이 — expanded일 때 TextEditor /// 텍스트가 그 밑에 깔리지 않도록 그만큼 하단 여백을 예약한다. @@ -47,7 +50,14 @@ struct MessageComposerView: View { .strokeBorder(Color.colorGray950, lineWidth: 1) ) - if input.isEmpty { + if isDisabled { + Text(disabledPlaceholder) + .typography(.subtitle3) + .foregroundStyle(Color.colorGray500) + .lineLimit(1) + .padding(.leading, Spacing.spacing300) + .padding(.top, Spacing.spacing300) + } else if input.isEmpty { Text("무슨 이야기를 버려볼까요?") .typography(.subtitle3) .foregroundStyle(Color.colorGray500) @@ -67,6 +77,7 @@ struct MessageComposerView: View { .padding(.top, 8) .padding(.bottom, controlsRowHeight) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .disabled(isDisabled) .focused(isFocused) .onChange(of: isFocused.wrappedValue) { _, isFocusedNow in if isFocusedNow { isEmotionPickerOpen = false } @@ -88,9 +99,9 @@ struct MessageComposerView: View { private var controlsRow: some View { HStack { - if isEmotionSelectionEnabled { emotionTrigger } + if !isDisabled, isEmotionSelectionEnabled { emotionTrigger } Spacer() - submitButton + if !isDisabled { submitButton } } .padding(.horizontal, Spacing.spacing300) .padding(.bottom, Spacing.spacing300) @@ -175,6 +186,7 @@ private struct MessageComposerPreviewContainer: View { @State var input: String @State var selectedEmotions: Set @State var isEmotionPickerOpen: Bool + var isDisabled: Bool = false @FocusState private var isFocused: Bool var body: some View { @@ -195,7 +207,9 @@ private struct MessageComposerPreviewContainer: View { input = newValue return false }, - isFocused: $isFocused + isFocused: $isFocused, + isDisabled: isDisabled, + disabledPlaceholder: "오늘의 토큰을 모두 사용했어요" ) .padding(Spacing.spacing400) } @@ -224,3 +238,12 @@ private struct MessageComposerPreviewContainer: View { isEmotionPickerOpen: true ) } + +#Preview("token exceeded") { + MessageComposerPreviewContainer( + input: "", + selectedEmotions: Set(EmotionCharacter.allCases), + isEmotionPickerOpen: false, + isDisabled: true + ) +} diff --git a/GAMSS/Sources/Presentation/Root/MainTabView.swift b/GAMSS/Sources/Presentation/Root/MainTabView.swift index 050f224..511efc6 100644 --- a/GAMSS/Sources/Presentation/Root/MainTabView.swift +++ b/GAMSS/Sources/Presentation/Root/MainTabView.swift @@ -43,6 +43,9 @@ struct MainTabView: View { viewModel: HomeViewModel( fetchMyProfileUseCase: DefaultFetchMyProfileUseCase( memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared) + ), + getTokenUsageUseCase: GetTokenUsageUseCase( + memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared) ) ) ) diff --git a/GAMSSTests/Presentation/HomeViewModelTests.swift b/GAMSSTests/Presentation/HomeViewModelTests.swift index 73019e3..d6e6fb2 100644 --- a/GAMSSTests/Presentation/HomeViewModelTests.swift +++ b/GAMSSTests/Presentation/HomeViewModelTests.swift @@ -18,14 +18,30 @@ private final class MockFetchMyProfileUseCase: FetchMyProfileUseCase { } } +private final class MockMemberRepository: MemberRepository { + var stubbedTokenUsageResult: Result = .failure(SummaryError.inferenceFailed()) + private(set) var fetchTokenUsageCallCount = 0 + + func deleteMember() async throws { fatalError("not used in this test") } + func fetchMyProfile() async throws -> User { fatalError("not used in this test") } + func updateNickname(_ nickname: String) async throws -> User { fatalError("not used in this test") } + + func fetchTokenUsage() async throws -> TokenUsage { + fetchTokenUsageCallCount += 1 + return try stubbedTokenUsageResult.get() + } +} + @MainActor final class HomeViewModelTests: XCTestCase { private func makeViewModel( fetchMyProfileUseCase: MockFetchMyProfileUseCase = MockFetchMyProfileUseCase(), + memberRepository: MockMemberRepository = MockMemberRepository(), userManager: UserManager = UserManager() ) -> HomeViewModel { HomeViewModel( fetchMyProfileUseCase: fetchMyProfileUseCase, + getTokenUsageUseCase: GetTokenUsageUseCase(memberRepository: memberRepository), userManager: userManager ) } @@ -183,4 +199,50 @@ final class HomeViewModelTests: XCTestCase { XCTAssertTrue(viewModel.selectedEmotions.isEmpty, "사전 조건: 전체 해제 상태여야 함") XCTAssertTrue(viewModel.isSendDisabled, "감정을 전체 제외하면 입력이 있어도 전송은 막혀야 함") } + + func test_loadTokenUsage_whenNotExceeded_doesNotDisableSend() async { + let memberRepository = MockMemberRepository() + memberRepository.stubbedTokenUsageResult = .success(TokenUsage(usedTokens: 10, dailyLimit: 100, exceeded: false)) + let viewModel = makeViewModel(memberRepository: memberRepository) + viewModel.input = "안녕" + + await viewModel.loadTokenUsage() + + XCTAssertFalse(viewModel.isTokenExceeded) + XCTAssertFalse(viewModel.isSendDisabled) + } + + func test_loadTokenUsage_whenExceeded_disablesSend() async { + let memberRepository = MockMemberRepository() + memberRepository.stubbedTokenUsageResult = .success(TokenUsage(usedTokens: 100, dailyLimit: 100, exceeded: true)) + let viewModel = makeViewModel(memberRepository: memberRepository) + viewModel.input = "안녕" + + await viewModel.loadTokenUsage() + + XCTAssertTrue(viewModel.isTokenExceeded) + XCTAssertTrue(viewModel.isSendDisabled, "토큰을 다 쓰면 입력이 있어도 전송이 막혀야 함") + } + + func test_loadTokenUsage_onFailure_leavesExceededStateUnchanged() async { + let memberRepository = MockMemberRepository() + memberRepository.stubbedTokenUsageResult = .failure(SummaryError.inferenceFailed()) + let viewModel = makeViewModel(memberRepository: memberRepository) + + await viewModel.loadTokenUsage() + + XCTAssertFalse(viewModel.isTokenExceeded, "조회 실패 시 조용히 무시하고 입력을 막지 않아야 함") + } + + func test_send_whenTokenExceeded_doesNotSetPendingFirstMessage() async { + let memberRepository = MockMemberRepository() + memberRepository.stubbedTokenUsageResult = .success(TokenUsage(usedTokens: 100, dailyLimit: 100, exceeded: true)) + let viewModel = makeViewModel(memberRepository: memberRepository) + await viewModel.loadTokenUsage() + viewModel.input = "안녕" + + viewModel.send() + + XCTAssertNil(viewModel.pendingFirstMessage, "토큰을 다 쓰면 전송 자체가 막혀야 함") + } } From dfbfd94bc3b129dc9f3bbb78cb76fd1f2197ea03 Mon Sep 17 00:00:00 2001 From: cchanmi Date: Fri, 21 Aug 2026 01:23:09 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[FIX]=20=EB=8C=80=ED=99=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=95=84=EC=9D=B4=EC=BD=98=EC=9D=B4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기어 아이콘이 탭 액션 없이 이미지로만 있어서 눌러도 아무 반응이 없었음. onTappedSettingButton 콜백을 추가하고 HomeView와 동일한 방식으로 SettingView에 navigationDestination 연결. --- .../Components/ConversationListHeaderView.swift | 9 +++++++-- .../ConversationList/ConversationListView.swift | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift b/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift index 9a0faae..c1039b6 100644 --- a/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift @@ -11,6 +11,7 @@ struct ConversationListHeaderView: View { var currentMode: ConversationMode var onTappedBackButton: (() -> Void)? var onTappedSearchButton: (() -> Void)? + var onTappedSettingButton: (() -> Void)? var body: some View { HStack(spacing: Spacing.spacing200) { @@ -38,8 +39,12 @@ struct ConversationListHeaderView: View { .foregroundStyle(Color.colorGray900) } - Image(.gear) - .foregroundStyle(Color.colorGray900) + Button { + onTappedSettingButton?() + } label: { + Image(.gear) + .foregroundStyle(Color.colorGray900) + } } .padding(.vertical, Spacing.spacing400) } diff --git a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift index 5f853cf..b7deff0 100644 --- a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift @@ -15,6 +15,7 @@ enum ConversationMode { struct ConversationListView: View { @StateObject private var viewModel: ConversationListViewModel @State private var selectedConversation: ConversationSummary? + @State private var isSettingPresented = false init(viewModel: ConversationListViewModel) { _viewModel = StateObject(wrappedValue: viewModel) @@ -28,6 +29,8 @@ struct ConversationListView: View { withAnimation(.easeInOut(duration: 0.3)) { viewModel.startSearching() } + }, onTappedSettingButton: { + isSettingPresented = true }) .frame(height: 64) @@ -135,6 +138,9 @@ struct ConversationListView: View { ) .toolbar(.hidden, for: .tabBar) } + .navigationDestination(isPresented: $isSettingPresented) { + SettingView().toolbar(.hidden, for: .tabBar) + } .onAppear { Task { await viewModel.load() } } From 77827f3d72a2bd22910613594e6690369c6bfd43 Mon Sep 17 00:00:00 2001 From: cchanmi Date: Fri, 21 Aug 2026 01:41:09 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[CHORE]=20CardResultViewModelTests.swift?= =?UTF-8?q?=EA=B0=80=20GAMSSTests=20=ED=83=80=EA=B2=9F=EC=97=90=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=95=88=20=EB=90=98=EC=96=B4=20=EC=9E=88?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일은 존재했지만 project.pbxproj에 빌드 대상으로 등록돼 있지 않아서, xcodebuild test를 아무리 돌려도 이 파일의 테스트 12개가 조용히 0개 실행되고 있었음(빌드/테스트 결과엔 실패로도 안 잡힘). 다른 테스트 파일과 동일한 방식으로 PBXBuildFile/PBXFileReference/그룹/Sources 빌드 페이즈에 등록. --- GAMSS.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/GAMSS.xcodeproj/project.pbxproj b/GAMSS.xcodeproj/project.pbxproj index 27396be..4137c92 100644 --- a/GAMSS.xcodeproj/project.pbxproj +++ b/GAMSS.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 94A526BCCCD9DF730A39BF97 /* sample_sentences.json in Resources */ = {isa = PBXBuildFile; fileRef = D4BE9D664889F13369325DC9 /* sample_sentences.json */; }; 96AB63E38D6D2755BA3BC839 /* ConversationPreviewTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA0CD379D5E578049585DD1 /* ConversationPreviewTextFormatterTests.swift */; }; 9EA6F6B8DF51021E18342083 /* HomeViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */; }; + 85725D9CB0A442D0A3F763B7 /* CardResultViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3A3B1AD8D344DFAB396C504 /* CardResultViewModelTests.swift */; }; A2EE79F9D91A740A8F752761 /* ConversationSummaryDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ACAF83D4A9CCBB62EFD5C84 /* ConversationSummaryDTOTests.swift */; }; A8948FBCA4DF4E43F2C582EA /* Pods_GAMSS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB14F9D00BB8B80EFE9A40A4 /* Pods_GAMSS.framework */; }; A8FEE0AD9A4A60908DC5F5F1 /* CreateMessageRequestDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DAA522A6DCECC0B3666523C /* CreateMessageRequestDTOTests.swift */; }; @@ -107,6 +108,7 @@ 3C15D652D0472A1EBD024674 /* MainTabTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabTests.swift; sourceTree = ""; }; 3C5DF3BD54F67AE200949A44 /* ChatComposerViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChatComposerViewTests.swift; sourceTree = ""; }; 3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HomeViewModelTests.swift; sourceTree = ""; }; + E3A3B1AD8D344DFAB396C504 /* CardResultViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CardResultViewModelTests.swift; sourceTree = ""; }; 411F8C36A4F71C3D2F78D616 /* CreateCardRequestDTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreateCardRequestDTOTests.swift; sourceTree = ""; }; 41CCBD8A019CAA37319E7984 /* ConversationSummaryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConversationSummaryPolicyTests.swift; sourceTree = ""; }; 4DDF78D0118869F3D9E1E269 /* SendMessageUseCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendMessageUseCaseTests.swift; sourceTree = ""; }; @@ -404,6 +406,7 @@ 02C393632C8A4F12516549D4 /* ConversationHistoryViewModelTests.swift */, B130F9698EB71973DB961378 /* MessageComposerLayoutTests.swift */, AB13C8E422F2136CAAAF20DF /* ChatViewModelRiskDetectionTests.swift */, + E3A3B1AD8D344DFAB396C504 /* CardResultViewModelTests.swift */, ); path = Presentation; sourceTree = ""; @@ -711,6 +714,7 @@ F29C98F332A3EC127B809DD3 /* ChatViewModelTests.swift in Sources */, FA90ADA39E7A145713881061 /* MainTabTests.swift in Sources */, 9EA6F6B8DF51021E18342083 /* HomeViewModelTests.swift in Sources */, + 85725D9CB0A442D0A3F763B7 /* CardResultViewModelTests.swift in Sources */, C63AB33F1424E4237FF76F6B /* SummaryErrorTests.swift in Sources */, 1D8DF6DFC7E7383B9B8C5850 /* DefaultSummaryRepositoryTests.swift in Sources */, F1D4E5A20F2B3C4D5E6F7A8B /* SpacingTests.swift in Sources */, From 10ab03b6076e1d8cb493e2d08b62cb9297576d6e Mon Sep 17 00:00:00 2001 From: cchanmi Date: Fri, 21 Aug 2026 02:06:44 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[FEAT]=20=EC=B9=B4=EB=93=9C=20=EB=B2=84?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=20=EC=8A=A4=EC=99=80=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=A5=BC=20opacity=20=EB=8C=80=EC=8B=A0=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20=EA=B3=84=EC=B8=B5=20=EC=88=9C=EC=84=9C=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동적으로 계층을 바꾸는 방식(dragOffset 임계값 넘으면 trashBin 뒤로 재배치)은 SwiftUI가 if/else 분기 전환 시 종이 뷰를 다시 만들면서 진행 중이던 오프셋 애니메이션이 끊겨, 일정 지점 아래로는 종이가 안 내려가는 문제가 있었음. 대신 종이/화살표를 항상 trashBin보다 뒤 계층에 고정해서, 아래로 스와이프하면 자연스럽게 쓰레기통 뒤로(안으로) 들어가는 것처럼 보이게 함 — 뷰가 다시 만들어지지 않으니 드래그/드롭 애니메이션이 끊기지 않는다. opacity(forDragOffset:)/isBehindTrashBin(dragOffset:) 모두 제거. 놓았을 때 손 뗀 위치에서 바로 닫히던 것도 이전 커밋에서 고친 대로 drop()으로 끝까지 떨어지는 애니메이션 재생 후 onComplete() 호출을 유지한다. --- .../CardResult/CardResultView.swift | 35 +++++++++++-------- .../CardResult/CardResultViewModel.swift | 11 +++--- .../CardResultViewModelTests.swift | 29 +++++---------- 3 files changed, 35 insertions(+), 40 deletions(-) diff --git a/GAMSS/Sources/Presentation/CardResult/CardResultView.swift b/GAMSS/Sources/Presentation/CardResult/CardResultView.swift index 6e3852d..847c264 100644 --- a/GAMSS/Sources/Presentation/CardResult/CardResultView.swift +++ b/GAMSS/Sources/Presentation/CardResult/CardResultView.swift @@ -84,6 +84,8 @@ struct CardResultView: View { private var discardableCard: some View { ZStack { + draggablePaper + trashBin .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .ignoresSafeArea(edges: .bottom) @@ -97,22 +99,23 @@ struct CardResultView: View { .fixedSize() .alignmentGuide(.top) { $0[.bottom] + 29 } } - - Image("cardFoldStepTwo") - .resizable() - .frame(width: foldStepTwoSize.width, height: foldStepTwoSize.height) - .overlay(alignment: .top) { - Image("cardDiscardArrow") - .resizable() - .frame(width: discardArrowSize.width, height: discardArrowSize.height) - .offset(y: 104) - } - .offset(y: viewModel.dragOffset) - .opacity(CardResultViewModel.opacity(forDragOffset: viewModel.dragOffset)) - .gesture(discardDragGesture) } } + private var draggablePaper: some View { + Image("cardFoldStepTwo") + .resizable() + .frame(width: foldStepTwoSize.width, height: foldStepTwoSize.height) + .overlay(alignment: .top) { + Image("cardDiscardArrow") + .resizable() + .frame(width: discardArrowSize.width, height: discardArrowSize.height) + .offset(y: 104) + } + .offset(y: viewModel.dragOffset) + .gesture(discardDragGesture) + } + private var trashBin: some View { Image("cardTrashBin") .resizable() @@ -128,7 +131,11 @@ struct CardResultView: View { } .onEnded { value in if CardResultViewModel.shouldDiscard(dragOffset: max(0, value.translation.height)) { - onComplete() + withAnimation(.easeIn(duration: 0.3)) { + viewModel.drop() + } completion: { + onComplete() + } } else { withAnimation(.spring()) { viewModel.resetDrag() diff --git a/GAMSS/Sources/Presentation/CardResult/CardResultViewModel.swift b/GAMSS/Sources/Presentation/CardResult/CardResultViewModel.swift index 238ef8c..8a5e834 100644 --- a/GAMSS/Sources/Presentation/CardResult/CardResultViewModel.swift +++ b/GAMSS/Sources/Presentation/CardResult/CardResultViewModel.swift @@ -30,7 +30,7 @@ final class CardResultViewModel: ObservableObject { @Published private(set) var dragOffset: CGFloat = 0 static let discardThreshold: CGFloat = 120 - static let fadeDistance: CGFloat = 240 + static let dropOffset: CGFloat = 900 func advanceStage() { guard let next = stage.next else { return } @@ -45,12 +45,11 @@ final class CardResultViewModel: ObservableObject { dragOffset = 0 } - static func shouldDiscard(dragOffset: CGFloat) -> Bool { - dragOffset > discardThreshold + func drop() { + dragOffset = Self.dropOffset } - static func opacity(forDragOffset dragOffset: CGFloat) -> Double { - let progress = min(max(dragOffset / fadeDistance, 0), 1) - return 1.0 - progress * 0.7 + static func shouldDiscard(dragOffset: CGFloat) -> Bool { + dragOffset > discardThreshold } } diff --git a/GAMSSTests/Presentation/CardResultViewModelTests.swift b/GAMSSTests/Presentation/CardResultViewModelTests.swift index c8ec012..18309c3 100644 --- a/GAMSSTests/Presentation/CardResultViewModelTests.swift +++ b/GAMSSTests/Presentation/CardResultViewModelTests.swift @@ -68,6 +68,15 @@ final class CardResultViewModelTests: XCTestCase { XCTAssertEqual(viewModel.dragOffset, 0) } + func test_drop_setsDragOffsetToDropOffset() { + let viewModel = CardResultViewModel() + viewModel.updateDrag(translationHeight: 130) + + viewModel.drop() + + XCTAssertEqual(viewModel.dragOffset, CardResultViewModel.dropOffset) + } + func test_shouldDiscard_belowThreshold_returnsFalse() { XCTAssertFalse(CardResultViewModel.shouldDiscard(dragOffset: 0)) XCTAssertFalse(CardResultViewModel.shouldDiscard(dragOffset: 119)) @@ -76,24 +85,4 @@ final class CardResultViewModelTests: XCTestCase { func test_shouldDiscard_aboveThreshold_returnsTrue() { XCTAssertTrue(CardResultViewModel.shouldDiscard(dragOffset: 121)) } - - func test_opacity_noOffset_isFullyOpaque() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: 0), 1.0) - } - - func test_opacity_atDiscardThreshold_isNotYetFullyFaded() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: 120), 0.65, accuracy: 0.0001) - } - - func test_opacity_atFadeDistance_isMinimumOpacity() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: 240), 0.3, accuracy: 0.0001) - } - - func test_opacity_beyondFadeDistance_staysClampedAtMinimum() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: 500), 0.3, accuracy: 0.0001) - } - - func test_opacity_negativeOffset_staysFullyOpaque() { - XCTAssertEqual(CardResultViewModel.opacity(forDragOffset: -50), 1.0) - } }