Skip to content
4 changes: 4 additions & 0 deletions GAMSS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -107,6 +108,7 @@
3C15D652D0472A1EBD024674 /* MainTabTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabTests.swift; sourceTree = "<group>"; };
3C5DF3BD54F67AE200949A44 /* ChatComposerViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChatComposerViewTests.swift; sourceTree = "<group>"; };
3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HomeViewModelTests.swift; sourceTree = "<group>"; };
E3A3B1AD8D344DFAB396C504 /* CardResultViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CardResultViewModelTests.swift; sourceTree = "<group>"; };
411F8C36A4F71C3D2F78D616 /* CreateCardRequestDTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreateCardRequestDTOTests.swift; sourceTree = "<group>"; };
41CCBD8A019CAA37319E7984 /* ConversationSummaryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConversationSummaryPolicyTests.swift; sourceTree = "<group>"; };
4DDF78D0118869F3D9E1E269 /* SendMessageUseCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendMessageUseCaseTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -404,6 +406,7 @@
02C393632C8A4F12516549D4 /* ConversationHistoryViewModelTests.swift */,
B130F9698EB71973DB961378 /* MessageComposerLayoutTests.swift */,
AB13C8E422F2136CAAAF20DF /* ChatViewModelRiskDetectionTests.swift */,
E3A3B1AD8D344DFAB396C504 /* CardResultViewModelTests.swift */,
);
path = Presentation;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
35 changes: 21 additions & 14 deletions GAMSS/Sources/Presentation/CardResult/CardResultView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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()
Expand Down
11 changes: 5 additions & 6 deletions GAMSS/Sources/Presentation/CardResult/CardResultViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
}
}
6 changes: 3 additions & 3 deletions GAMSS/Sources/Presentation/Chat/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ final class ChatViewModel: ObservableObject {
flushPendingComments()

let replyTarget = replyTarget
self.replyTarget = nil
pendingUserMessage = PendingUserMessage(
content: trimmed,
sentAt: Date(),
Expand All @@ -222,9 +223,6 @@ final class ChatViewModel: ObservableObject {
excludedCharacters: excludedCharacters
)
pendingUserMessage = nil
if self.replyTarget == replyTarget {
self.replyTarget = nil
}
seed(with: sent)

// 요약기(온디바이스 추론)가 끝날 때까지 다음 입력을 막지 않도록 백그라운드로 돌린다.
Expand All @@ -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 = "메시지를 보내지 못했어요"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -28,6 +29,8 @@ struct ConversationListView: View {
withAnimation(.easeInOut(duration: 0.3)) {
viewModel.startSearching()
}
}, onTappedSettingButton: {
isSettingPresented = true
})
.frame(height: 64)

Expand Down Expand Up @@ -135,6 +138,9 @@ struct ConversationListView: View {
)
.toolbar(.hidden, for: .tabBar)
}
.navigationDestination(isPresented: $isSettingPresented) {
SettingView().toolbar(.hidden, for: .tabBar)
}
.onAppear {
Task { await viewModel.load() }
}
Expand Down
22 changes: 20 additions & 2 deletions GAMSS/Sources/Presentation/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -52,6 +53,7 @@ struct HomeView: View {

greeting
.padding(.bottom, Spacing.spacing500)
.opacity(isGreetingReady ? 1 : 0)

MessageComposerView(
input: $viewModel.input,
Expand All @@ -61,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()
Expand Down Expand Up @@ -95,7 +99,18 @@ struct HomeView: View {
.navigationDestination(isPresented: $isSettingPresented) {
SettingView().toolbar(.hidden, for: .tabBar)
}
.task { await viewModel.loadProfileIfNeeded() }
.task {
if userManager.user != nil { isGreetingReady = true }
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 }
withAnimation(.easeInOut(duration: 0.3)) {
isGreetingReady = true
}
}
.alert(viewModel.alertMessage ?? "", isPresented: Binding(
get: { viewModel.alertMessage != nil },
set: { if !$0 { viewModel.alertMessage = nil } }
Expand Down Expand Up @@ -188,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)
)
)
)
Expand Down
22 changes: 20 additions & 2 deletions GAMSS/Sources/Presentation/Home/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,21 @@ final class HomeViewModel: ObservableObject {
@Published var pendingFirstMessage: PendingFirstMessage?
@Published private(set) var selectedEmotions: Set<EmotionCharacter> = 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
}

Expand All @@ -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가
Expand All @@ -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
Expand Down
31 changes: 27 additions & 4 deletions GAMSS/Sources/Presentation/Home/MessageComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ struct MessageComposerView: View {
/// 포커스 상태는 상위(HomeView)가 소유한다 — 빈 화면 탭으로 키보드를 내리는 처리가 상위에
/// 있어, 이 뷰가 자체 `@FocusState`를 따로 가지면 상위에서 그 상태를 제어할 수 없다.
var isFocused: FocusState<Bool>.Binding
/// 오늘의 토큰을 모두 썼으면 true — 입력 자체를 막고 감정/전송 컨트롤도 숨긴다.
let isDisabled: Bool
let disabledPlaceholder: String

/// 박스 하단 컨트롤 행(감정 트리거/전송 버튼)이 차지하는 높이 — expanded일 때 TextEditor
/// 텍스트가 그 밑에 깔리지 않도록 그만큼 하단 여백을 예약한다.
Expand All @@ -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)
Expand All @@ -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 }
Expand All @@ -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)
Expand Down Expand Up @@ -175,6 +186,7 @@ private struct MessageComposerPreviewContainer: View {
@State var input: String
@State var selectedEmotions: Set<EmotionCharacter>
@State var isEmotionPickerOpen: Bool
var isDisabled: Bool = false
@FocusState private var isFocused: Bool

var body: some View {
Expand All @@ -195,7 +207,9 @@ private struct MessageComposerPreviewContainer: View {
input = newValue
return false
},
isFocused: $isFocused
isFocused: $isFocused,
isDisabled: isDisabled,
disabledPlaceholder: "오늘의 토큰을 모두 사용했어요"
)
.padding(Spacing.spacing400)
}
Expand Down Expand Up @@ -224,3 +238,12 @@ private struct MessageComposerPreviewContainer: View {
isEmotionPickerOpen: true
)
}

#Preview("token exceeded") {
MessageComposerPreviewContainer(
input: "",
selectedEmotions: Set(EmotionCharacter.allCases),
isEmotionPickerOpen: false,
isDisabled: true
)
}
3 changes: 3 additions & 0 deletions GAMSS/Sources/Presentation/Root/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)
)
Expand Down
Loading
Loading