From 67c87c4287c2e65b854b85f42912e95f10978453 Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Mon, 24 Aug 2026 12:58:55 +0900 Subject: [PATCH 01/11] =?UTF-8?q?[CHORE]=20CardShredView=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20->=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Archive/Detail/ArchiveDetailView.swift | 2 +- .../CardDetail/CardDetailView.swift | 129 +++++++++--------- 2 files changed, 67 insertions(+), 64 deletions(-) diff --git a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift index 0c5e03a..a78b578 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift @@ -107,7 +107,7 @@ struct ArchiveDetailView: View { ) .presentationBackground(.clear) } - .fullScreenCover(isPresented: $isShredPresented) { + .navigationDestination(isPresented: $isShredPresented) { CardShredView( viewModel: CardShredViewModel( deleteAllCardUseCase: DefaultDeleteAllCardUseCase( diff --git a/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift b/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift index e39e3f3..0edbc89 100644 --- a/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift +++ b/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift @@ -30,77 +30,80 @@ struct CardDetailView: View { } var body: some View { - ZStack { - Color.colorBlack.opacity(0.7) - .ignoresSafeArea() + NavigationStack { + ZStack { + Color.colorBlack.opacity(0.7) + .ignoresSafeArea() - if let card = viewModel.card { - if isShowingConversation { - ConversationHistoryView( - card: card, - viewModel: conversationHistoryViewModel, - onBack: { showCard() }, - onClose: onClose - ) - .transition(.scale.combined(with: .opacity)) - } else { - ZStack(alignment: .topTrailing) { - CardView(card: card) { - bottomActions - } + if let card = viewModel.card { + if isShowingConversation { + ConversationHistoryView( + card: card, + viewModel: conversationHistoryViewModel, + onBack: { showCard() }, + onClose: onClose + ) + .transition(.scale.combined(with: .opacity)) + } else { + ZStack(alignment: .topTrailing) { + CardView(card: card) { + bottomActions + } - closeButton - .padding([.top, .trailing], Spacing.spacing300) + closeButton + .padding([.top, .trailing], Spacing.spacing300) + } + .transition(.scale.combined(with: .opacity)) } - .transition(.scale.combined(with: .opacity)) + } else if viewModel.isLoading { + ProgressView() + .tint(Color.colorWhite) } - } else if viewModel.isLoading { - ProgressView() - .tint(Color.colorWhite) } - } - .task { - await viewModel.loadCard() - } - .alert( - viewModel.alertMessage ?? "", - isPresented: Binding( - get: { viewModel.alertMessage != nil }, - set: { if !$0 { viewModel.alertMessage = nil } } - ) - ) { - Button("다시 시도") { - let isLoadFailure = viewModel.isLoadFailureAlert - Task { - if isLoadFailure { - await viewModel.loadCard() + .toolbar(.hidden, for: .navigationBar) + .task { + await viewModel.loadCard() + } + .alert( + viewModel.alertMessage ?? "", + isPresented: Binding( + get: { viewModel.alertMessage != nil }, + set: { if !$0 { viewModel.alertMessage = nil } } + ) + ) { + Button("다시 시도") { + let isLoadFailure = viewModel.isLoadFailureAlert + Task { + if isLoadFailure { + await viewModel.loadCard() + } } } + if viewModel.isLoadFailureAlert { + Button("닫기", role: .cancel) { onClose() } + } else { + Button("확인", role: .cancel) {} + } } - if viewModel.isLoadFailureAlert { - Button("닫기", role: .cancel) { onClose() } - } else { - Button("확인", role: .cancel) {} - } - } - .fullScreenCover(isPresented: $isShredPresented) { - if let card = viewModel.card { - CardShredView( - viewModel: CardShredViewModel( - cardId: card.id, - deleteCardUseCase: DeleteCardUseCase( - cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) - ) - ), - stripImageName: "noteStrip", - onBack: { - isShredPresented = false - }, - onComplete: { - isShredPresented = false - onClose() - } - ) + .navigationDestination(isPresented: $isShredPresented) { + if let card = viewModel.card { + CardShredView( + viewModel: CardShredViewModel( + cardId: card.id, + deleteCardUseCase: DeleteCardUseCase( + cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) + ) + ), + stripImageName: "noteStrip", + onBack: { + isShredPresented = false + }, + onComplete: { + isShredPresented = false + onClose() + } + ) + } } } } From c972339117eb762d0277d13f708709b3a29fbaa0 Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Mon, 24 Aug 2026 13:02:52 +0900 Subject: [PATCH 02/11] =?UTF-8?q?[FIX]=20=EC=B9=B4=EB=93=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20=ED=9B=84=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20alert=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift b/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift index 0edbc89..96dd717 100644 --- a/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift +++ b/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift @@ -62,6 +62,7 @@ struct CardDetailView: View { } .toolbar(.hidden, for: .navigationBar) .task { + guard viewModel.card == nil else { return } await viewModel.loadCard() } .alert( @@ -99,7 +100,6 @@ struct CardDetailView: View { isShredPresented = false }, onComplete: { - isShredPresented = false onClose() } ) From 4c79ac771295e667a7ce50cd75a631505d1d7465 Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Mon, 24 Aug 2026 13:10:51 +0900 Subject: [PATCH 03/11] =?UTF-8?q?[FEAT]=20CardDetailView=EB=9D=84=EC=9B=8C?= =?UTF-8?q?=EC=A7=88=EB=95=8C=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Archive/Detail/ArchiveDetailView.swift | 40 +++++++++++-------- .../Sources/Presentation/Chat/ChatView.swift | 36 +++++++++-------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift index a78b578..ecefe64 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift @@ -51,7 +51,9 @@ struct ArchiveDetailView: View { scene.scaleMode = .resizeFill scene.updateSize(proxy.size) scene.onSelectNote = { note in - selectedNote = note + withAnimation(.easeOut(duration: 0.12)) { + selectedNote = note + } } scene.render(notes: viewModel.notes) } @@ -92,21 +94,6 @@ struct ArchiveDetailView: View { } } } - .fullScreenCover(item: $selectedNote) { note in - CardDetailView( - viewModel: CardDetailViewModel( - cardId: note.id, - getCardUseCase: GetCardUseCase( - cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) - ) - ), - onClose: { - selectedNote = nil - Task { await viewModel.load() } - } - ) - .presentationBackground(.clear) - } .navigationDestination(isPresented: $isShredPresented) { CardShredView( viewModel: CardShredViewModel( @@ -126,7 +113,28 @@ struct ArchiveDetailView: View { } ) } + + if let note = selectedNote { + CardDetailView( + viewModel: CardDetailViewModel( + cardId: note.id, + getCardUseCase: GetCardUseCase( + cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) + ) + ), + onClose: { + withAnimation(.easeOut(duration: 0.12)) { + selectedNote = nil + } + Task { await viewModel.load() } + } + ) + .id(note.id) + .transition(.opacity) + .zIndex(1) + } } + .animation(.easeOut(duration: 0.12), value: selectedNote?.id) } private var header: some View { diff --git a/GAMSS/Sources/Presentation/Chat/ChatView.swift b/GAMSS/Sources/Presentation/Chat/ChatView.swift index b1e3e3b..597ff37 100644 --- a/GAMSS/Sources/Presentation/Chat/ChatView.swift +++ b/GAMSS/Sources/Presentation/Chat/ChatView.swift @@ -91,7 +91,26 @@ struct ChatView: View { .tint(Color.colorWhite) .frame(maxWidth: .infinity, maxHeight: .infinity) } + + if let card = viewModel.createdCard { + CardResultView( + card: card, + viewModel: CardResultViewModel(), + onComplete: { + withAnimation(.easeOut(duration: 0.12)) { + viewModel.dismissCard() + } + DispatchQueue.main.async { + dismiss() + } + } + ) + .id(card.id) + .transition(.opacity) + .zIndex(2) + } } + .animation(.easeOut(duration: 0.12), value: viewModel.createdCard?.id) .onChange(of: viewModel.riskDetection) { _, newValue in if newValue != nil { isInputFocused = false } } @@ -231,23 +250,6 @@ struct ChatView: View { } Button("확인", role: .cancel) {} } - .fullScreenCover(item: Binding( - get: { viewModel.createdCard }, - set: { if $0 == nil { viewModel.dismissCard() } } - )) { card in - CardResultView( - card: card, - viewModel: CardResultViewModel(), - onComplete: { - viewModel.dismissCard() - - DispatchQueue.main.async { - dismiss() - } - } - ) - .presentationBackground(.clear) - } } /// 커스텀 상단 헤더: 뒤로가기 + 대화방 생성 날짜 + 우측 버튼 2개(종료, 토큰 사용량). From 701ead635d176ba730eea451660509de99f250bd Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Mon, 24 Aug 2026 13:42:25 +0900 Subject: [PATCH 04/11] =?UTF-8?q?[FIX]=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B0=8F=20=ED=83=AD=EB=B0=94=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Navigation/NavigationBarHider.swift | 92 +++++++++++++ .../Presentation/Archive/ArchiveView.swift | 35 +++-- .../Archive/Detail/ArchiveDetailView.swift | 45 +++++-- .../CardDetail/CardDetailView.swift | 124 ++++++++---------- .../CardShred/CardShredView.swift | 1 - .../CardShred/CardShredViewModel.swift | 4 +- .../Sources/Presentation/Chat/ChatView.swift | 3 +- .../ConversationListView.swift | 4 +- .../Sources/Presentation/Home/HomeView.swift | 4 +- .../Presentation/Root/MainTabView.swift | 36 +++-- .../Setting/Account/AccountView.swift | 2 - .../Presentation/Setting/SettingView.swift | 1 - 12 files changed, 230 insertions(+), 121 deletions(-) create mode 100644 GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift diff --git a/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift b/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift new file mode 100644 index 0000000..d627ac5 --- /dev/null +++ b/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift @@ -0,0 +1,92 @@ +// +// NavigationBarHider.swift +// GAMSS +// +// Created by 이건준 on 8/24/26. +// + +import SwiftUI + +/// NavigationStack 기준으로 +/// - 네비게이션 바: 항상 숨김 +/// - 탭 바: 루트에서만 표시, push된 화면에서는 숨김 +struct NavigationBarHider: UIViewControllerRepresentable { + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIViewController(context: Context) -> UIViewController { + let controller = HostViewController() + controller.onAppear = { [weak coordinator = context.coordinator, weak controller] in + guard let controller else { return } + coordinator?.bind(from: controller) + } + context.coordinator.bind(from: controller) + return controller + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + context.coordinator.bind(from: uiViewController) + } + + final class HostViewController: UIViewController { + var onAppear: (() -> Void)? + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + onAppear?() + } + } + + final class Coordinator: NSObject, UINavigationControllerDelegate { + private weak var navigationController: UINavigationController? + private weak var originalDelegate: UINavigationControllerDelegate? + + func bind(from controller: UIViewController) { + DispatchQueue.main.async { [weak self, weak controller] in + guard let self, let navigationController = controller?.navigationController else { return } + + if self.navigationController !== navigationController { + self.originalDelegate = navigationController.delegate + self.navigationController = navigationController + navigationController.delegate = self + } + + self.applyChrome(for: navigationController) + } + } + + func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + applyChrome(for: navigationController) + originalDelegate?.navigationController?( + navigationController, + willShow: viewController, + animated: animated + ) + } + + func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + applyChrome(for: navigationController) + originalDelegate?.navigationController?( + navigationController, + didShow: viewController, + animated: animated + ) + } + + private func applyChrome(for navigationController: UINavigationController) { + navigationController.setNavigationBarHidden(true, animated: false) + + let isRoot = navigationController.viewControllers.count <= 1 + navigationController.tabBarController?.tabBar.isHidden = !isRoot + } + } +} diff --git a/GAMSS/Sources/Presentation/Archive/ArchiveView.swift b/GAMSS/Sources/Presentation/Archive/ArchiveView.swift index e97ebe5..fee6c5b 100644 --- a/GAMSS/Sources/Presentation/Archive/ArchiveView.swift +++ b/GAMSS/Sources/Presentation/Archive/ArchiveView.swift @@ -16,26 +16,25 @@ struct ArchiveView: View { } var body: some View { - NavigationStack { - VStack(spacing: 0) { - header - .padding(.bottom, 32) - - ScrollView { - VStack(alignment: .center, spacing: 34) { - Text("다시 보고 싶은 쓰레기통을 열어보세요") - .typography(.body4Medium) - .foregroundStyle(Color.colorGray950) - .padding(.bottom, 2) - - trashCanGrid - } - .padding(.bottom, 33) + VStack(spacing: 0) { + header + .padding(.bottom, 32) + + ScrollView { + VStack(alignment: .center, spacing: 34) { + Text("다시 보고 싶은 쓰레기통을 열어보세요") + .typography(.body4Medium) + .foregroundStyle(Color.colorGray950) + .padding(.bottom, 2) + + trashCanGrid } + .padding(.bottom, 33) } - .background(Color.colorWhite) - }.navigationDestination(isPresented: $isSettingPresented) { - SettingView().toolbar(.hidden, for: .tabBar) + } + .background(Color.colorWhite) + .navigationDestination(isPresented: $isSettingPresented) { + SettingView() } } } diff --git a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift index ecefe64..d580bbd 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift @@ -13,7 +13,7 @@ struct ArchiveDetailView: View { @StateObject private var viewModel: ArchiveDetailViewModel @State private var scene = DropStackScene() @State private var isMonthPickerPresented = false - @State private var isShredPresented = false + @State private var shredMode: CardShredMode? @State private var selectedNote: DropNote? private let title: String @@ -64,7 +64,6 @@ struct ArchiveDetailView: View { } } } - .toolbar(.hidden, for: .navigationBar) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.colorWhite) .overlay { @@ -94,19 +93,19 @@ struct ArchiveDetailView: View { } } } - .navigationDestination(isPresented: $isShredPresented) { + .navigationDestination(item: $shredMode) { mode in CardShredView( - viewModel: CardShredViewModel( - deleteAllCardUseCase: DefaultDeleteAllCardUseCase( - cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) - ) - ), + viewModel: makeShredViewModel(for: mode), stripImageName: "noteStrip", onBack: { - isShredPresented = false + if case .single(let cardId) = mode { + selectedNote = viewModel.notes.first { $0.id == cardId } + } + shredMode = nil }, onComplete: { - isShredPresented = false + shredMode = nil + selectedNote = nil viewModel.clearNotes() scene.clear() Task { await viewModel.load() } @@ -127,6 +126,15 @@ struct ArchiveDetailView: View { selectedNote = nil } Task { await viewModel.load() } + }, + onDiscard: { + let cardId = note.id + shredMode = .single(cardId: cardId) + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + selectedNote = nil + } } ) .id(note.id) @@ -136,6 +144,21 @@ struct ArchiveDetailView: View { } .animation(.easeOut(duration: 0.12), value: selectedNote?.id) } + + private func makeShredViewModel(for mode: CardShredMode) -> CardShredViewModel { + let cardRepository = DefaultCardRepository(networkManager: NetworkManager.shared) + switch mode { + case .single(let cardId): + return CardShredViewModel( + cardId: cardId, + deleteCardUseCase: DeleteCardUseCase(cardRepository: cardRepository) + ) + case .all: + return CardShredViewModel( + deleteAllCardUseCase: DefaultDeleteAllCardUseCase(cardRepository: cardRepository) + ) + } + } private var header: some View { HStack(spacing: Spacing.spacing200) { @@ -153,7 +176,7 @@ struct ArchiveDetailView: View { Spacer() Button { - isShredPresented = true + shredMode = .all } label: { Text("비우기") .typography(.body5Medium) diff --git a/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift b/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift index 96dd717..77dcfe8 100644 --- a/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift +++ b/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift @@ -9,16 +9,21 @@ import SwiftUI struct CardDetailView: View { let onClose: () -> Void + let onDiscard: () -> Void @StateObject private var viewModel: CardDetailViewModel @StateObject private var conversationHistoryViewModel: ConversationHistoryViewModel @State private var isShowingConversation = false - @State private var isShredPresented = false private let transitionDuration = 0.22 - init(viewModel: CardDetailViewModel, onClose: @escaping () -> Void) { + init( + viewModel: CardDetailViewModel, + onClose: @escaping () -> Void, + onDiscard: @escaping () -> Void + ) { self.onClose = onClose + self.onDiscard = onDiscard _viewModel = StateObject(wrappedValue: viewModel) _conversationHistoryViewModel = StateObject( wrappedValue: ConversationHistoryViewModel( @@ -30,80 +35,58 @@ struct CardDetailView: View { } var body: some View { - NavigationStack { - ZStack { - Color.colorBlack.opacity(0.7) - .ignoresSafeArea() + ZStack { + Color.colorBlack.opacity(0.7) + .ignoresSafeArea() - if let card = viewModel.card { - if isShowingConversation { - ConversationHistoryView( - card: card, - viewModel: conversationHistoryViewModel, - onBack: { showCard() }, - onClose: onClose - ) - .transition(.scale.combined(with: .opacity)) - } else { - ZStack(alignment: .topTrailing) { - CardView(card: card) { - bottomActions - } - - closeButton - .padding([.top, .trailing], Spacing.spacing300) + if let card = viewModel.card { + if isShowingConversation { + ConversationHistoryView( + card: card, + viewModel: conversationHistoryViewModel, + onBack: { showCard() }, + onClose: onClose + ) + .transition(.scale.combined(with: .opacity)) + } else { + ZStack(alignment: .topTrailing) { + CardView(card: card) { + bottomActions } - .transition(.scale.combined(with: .opacity)) + + closeButton + .padding([.top, .trailing], Spacing.spacing300) } - } else if viewModel.isLoading { - ProgressView() - .tint(Color.colorWhite) + .transition(.scale.combined(with: .opacity)) } + } else if viewModel.isLoading { + ProgressView() + .tint(Color.colorWhite) } - .toolbar(.hidden, for: .navigationBar) - .task { - guard viewModel.card == nil else { return } - await viewModel.loadCard() - } - .alert( - viewModel.alertMessage ?? "", - isPresented: Binding( - get: { viewModel.alertMessage != nil }, - set: { if !$0 { viewModel.alertMessage = nil } } - ) - ) { - Button("다시 시도") { - let isLoadFailure = viewModel.isLoadFailureAlert - Task { - if isLoadFailure { - await viewModel.loadCard() - } + } + .task { + guard viewModel.card == nil else { return } + await viewModel.loadCard() + } + .alert( + viewModel.alertMessage ?? "", + isPresented: Binding( + get: { viewModel.alertMessage != nil }, + set: { if !$0 { viewModel.alertMessage = nil } } + ) + ) { + Button("다시 시도") { + let isLoadFailure = viewModel.isLoadFailureAlert + Task { + if isLoadFailure { + await viewModel.loadCard() } } - if viewModel.isLoadFailureAlert { - Button("닫기", role: .cancel) { onClose() } - } else { - Button("확인", role: .cancel) {} - } } - .navigationDestination(isPresented: $isShredPresented) { - if let card = viewModel.card { - CardShredView( - viewModel: CardShredViewModel( - cardId: card.id, - deleteCardUseCase: DeleteCardUseCase( - cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) - ) - ), - stripImageName: "noteStrip", - onBack: { - isShredPresented = false - }, - onComplete: { - onClose() - } - ) - } + if viewModel.isLoadFailureAlert { + Button("닫기", role: .cancel) { onClose() } + } else { + Button("확인", role: .cancel) {} } } } @@ -111,7 +94,7 @@ struct CardDetailView: View { private var bottomActions: some View { HStack(spacing: Spacing.spacing050) { OutlineButton(title: "기록 버리기") { - isShredPresented = true + onDiscard() } .frame(width: 97) @@ -154,6 +137,7 @@ struct CardDetailView: View { cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) ) ), - onClose: {} + onClose: {}, + onDiscard: {} ) } diff --git a/GAMSS/Sources/Presentation/CardShred/CardShredView.swift b/GAMSS/Sources/Presentation/CardShred/CardShredView.swift index e92c28a..847a443 100644 --- a/GAMSS/Sources/Presentation/CardShred/CardShredView.swift +++ b/GAMSS/Sources/Presentation/CardShred/CardShredView.swift @@ -56,7 +56,6 @@ struct CardShredView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.colorWhite) - .toolbar(.hidden, for: .navigationBar) .alert( viewModel.alertMessage ?? "", isPresented: Binding( diff --git a/GAMSS/Sources/Presentation/CardShred/CardShredViewModel.swift b/GAMSS/Sources/Presentation/CardShred/CardShredViewModel.swift index bd8594c..df477f4 100644 --- a/GAMSS/Sources/Presentation/CardShred/CardShredViewModel.swift +++ b/GAMSS/Sources/Presentation/CardShred/CardShredViewModel.swift @@ -8,9 +8,11 @@ import Combine import Foundation -enum CardShredMode { +enum CardShredMode: Hashable, Identifiable { case single(cardId: Int) case all + + var id: Self { self } } @MainActor diff --git a/GAMSS/Sources/Presentation/Chat/ChatView.swift b/GAMSS/Sources/Presentation/Chat/ChatView.swift index 597ff37..b13a415 100644 --- a/GAMSS/Sources/Presentation/Chat/ChatView.swift +++ b/GAMSS/Sources/Presentation/Chat/ChatView.swift @@ -228,7 +228,6 @@ struct ChatView: View { } } } - .toolbar(.hidden, for: .navigationBar) .task { await viewModel.start() } @@ -253,7 +252,7 @@ struct ChatView: View { } /// 커스텀 상단 헤더: 뒤로가기 + 대화방 생성 날짜 + 우측 버튼 2개(종료, 토큰 사용량). - /// 시스템 네비게이션 바는 `.toolbar(.hidden, for: .navigationBar)`로 숨기고 이 헤더가 대신한다. + /// 시스템 네비게이션 바는 MainTabView의 NavigationStack에서 숨긴다. private var header: some View { ZStack { Text(headerDateText) diff --git a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift index 1e4c474..633daf5 100644 --- a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift @@ -121,7 +121,6 @@ struct ConversationListView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .padding(.horizontal, Spacing.spacing400) - .toolbar(.hidden, for: .navigationBar) .navigationDestination(item: $selectedConversation) { conversation in ChatView( viewModel: ChatViewModel( @@ -137,10 +136,9 @@ struct ConversationListView: View { initialDate: conversation.createdAt ) ) - .toolbar(.hidden, for: .tabBar) } .navigationDestination(isPresented: $isSettingPresented) { - SettingView().toolbar(.hidden, for: .tabBar) + SettingView() } .onAppear { Task { await viewModel.load() } diff --git a/GAMSS/Sources/Presentation/Home/HomeView.swift b/GAMSS/Sources/Presentation/Home/HomeView.swift index ee23a3e..b00a870 100644 --- a/GAMSS/Sources/Presentation/Home/HomeView.swift +++ b/GAMSS/Sources/Presentation/Home/HomeView.swift @@ -76,7 +76,6 @@ struct HomeView: View { // 이미지가 GeometryReader의 상대 좌표(geo.size)로 위치를 잡고 있어서 그 영역이 // 줄어들면 같이 움직여 보인다 — 키보드에 반응해 레이아웃이 줄어들지 않게 한다. .ignoresSafeArea(.keyboard, edges: .bottom) - .navigationBarHidden(true) .onChange(of: viewModel.pendingFirstMessage) { _, newValue in if newValue != nil { isInputFocused = false } } @@ -94,10 +93,9 @@ struct HomeView: View { pendingFirstMessage: pendingFirstMessage ) ) - .toolbar(.hidden, for: .tabBar) } .navigationDestination(isPresented: $isSettingPresented) { - SettingView().toolbar(.hidden, for: .tabBar) + SettingView() } .task { if userManager.user != nil { isGreetingReady = true } diff --git a/GAMSS/Sources/Presentation/Root/MainTabView.swift b/GAMSS/Sources/Presentation/Root/MainTabView.swift index 511efc6..3c3ce19 100644 --- a/GAMSS/Sources/Presentation/Root/MainTabView.swift +++ b/GAMSS/Sources/Presentation/Root/MainTabView.swift @@ -33,12 +33,14 @@ struct MainTabView: View { } var body: some View { - NavigationStack { - TabView(selection: $selectedTab) { + TabView(selection: $selectedTab) { + tabNavigationStack { ArchiveView(viewModel: ArchiveViewModel()) - .tabItem { tabLabel(for: .archive) } - .tag(MainTab.archive) + } + .tabItem { tabLabel(for: .archive) } + .tag(MainTab.archive) + tabNavigationStack { HomeView( viewModel: HomeViewModel( fetchMyProfileUseCase: DefaultFetchMyProfileUseCase( @@ -49,19 +51,35 @@ struct MainTabView: View { ) ) ) - .tabItem { tabLabel(for: .home) } - .tag(MainTab.home) + } + .tabItem { tabLabel(for: .home) } + .tag(MainTab.home) + tabNavigationStack { ConversationListView( viewModel: ConversationListViewModel( getIncompleteConversationsUseCase: GetIncompleteConversationsUseCase( conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared) - ), deleteConversationsUseCase: DefaultDeleteConversationsUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), searchConversationUseCase: DefaultSearchConversationUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)) + ), + deleteConversationsUseCase: DefaultDeleteConversationsUseCase( + conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared) + ), + searchConversationUseCase: DefaultSearchConversationUseCase( + conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared) + ) ) ) - .tabItem { tabLabel(for: .chat) } - .tag(MainTab.chat) } + .tabItem { tabLabel(for: .chat) } + .tag(MainTab.chat) + } + } + + /// 탭별 NavigationStack. 네비게이션 바/탭 바 표시는 NavigationBarHider에서 처리한다. + private func tabNavigationStack(@ViewBuilder content: () -> Content) -> some View { + NavigationStack { + content() + .background(NavigationBarHider()) } } diff --git a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift index 105e6a7..edbffde 100644 --- a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift +++ b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift @@ -38,7 +38,6 @@ struct AccountView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color.colorWhite) - .toolbar(.hidden, for: .navigationBar) .alert( viewModel.errorMessage ?? "", isPresented: Binding( @@ -117,7 +116,6 @@ struct AccountView: View { case .changeNickname: NavigationLink { NicknameEditView(viewModel: NicknameEditViewModel(updateNicknameUseCase: DefaultUpdateNicknameUseCase(memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: TokenStorage.shared), userManager: UserManager.shared))) - .toolbar(.hidden, for: .navigationBar) } label: { row } diff --git a/GAMSS/Sources/Presentation/Setting/SettingView.swift b/GAMSS/Sources/Presentation/Setting/SettingView.swift index ee33eb0..adad1c0 100644 --- a/GAMSS/Sources/Presentation/Setting/SettingView.swift +++ b/GAMSS/Sources/Presentation/Setting/SettingView.swift @@ -32,7 +32,6 @@ struct SettingView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.colorWhite) - .toolbar(.hidden, for: .navigationBar) .sheet(item: $presentedWebPage) { page in SafariView(url: page.url) .ignoresSafeArea() From a7920026af4edda9d79a6609b419eab2eac50743 Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Mon, 24 Aug 2026 13:46:09 +0900 Subject: [PATCH 05/11] =?UTF-8?q?[FIX]=20=ED=99=94=EB=A9=B4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=8B=9C=20=ED=83=AD=EB=B0=94=EB=A7=8C=ED=81=BC=20?= =?UTF-8?q?=EA=B0=84=EA=B2=A9=20=EC=83=9D=EA=B8=B0=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=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 --- .../Navigation/NavigationBarHider.swift | 27 ++++++++++++------- .../Archive/Detail/ArchiveDetailView.swift | 1 + .../CardShred/CardShredView.swift | 1 + .../Sources/Presentation/Chat/ChatView.swift | 1 + .../Presentation/Root/MainTabView.swift | 2 +- .../Setting/Account/AccountView.swift | 1 + .../NicknameEdit/NicknameEditView.swift | 1 + .../Presentation/Setting/SettingView.swift | 1 + 8 files changed, 24 insertions(+), 11 deletions(-) diff --git a/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift b/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift index d627ac5..2f8bdf0 100644 --- a/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift +++ b/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift @@ -7,9 +7,8 @@ import SwiftUI -/// NavigationStack 기준으로 -/// - 네비게이션 바: 항상 숨김 -/// - 탭 바: 루트에서만 표시, push된 화면에서는 숨김 +/// NavigationStack 안의 시스템 네비게이션 바를 항상 숨긴다. +/// 탭 바는 SwiftUI `.toolbar(.hidden, for: .tabBar)`로 처리해야 레이아웃이 전체 높이로 확장된다. struct NavigationBarHider: UIViewControllerRepresentable { func makeCoordinator() -> Coordinator { Coordinator() @@ -52,7 +51,7 @@ struct NavigationBarHider: UIViewControllerRepresentable { navigationController.delegate = self } - self.applyChrome(for: navigationController) + self.hideNavigationBar(on: navigationController) } } @@ -61,7 +60,7 @@ struct NavigationBarHider: UIViewControllerRepresentable { willShow viewController: UIViewController, animated: Bool ) { - applyChrome(for: navigationController) + hideNavigationBar(on: navigationController) originalDelegate?.navigationController?( navigationController, willShow: viewController, @@ -74,7 +73,7 @@ struct NavigationBarHider: UIViewControllerRepresentable { didShow viewController: UIViewController, animated: Bool ) { - applyChrome(for: navigationController) + hideNavigationBar(on: navigationController) originalDelegate?.navigationController?( navigationController, didShow: viewController, @@ -82,11 +81,19 @@ struct NavigationBarHider: UIViewControllerRepresentable { ) } - private func applyChrome(for navigationController: UINavigationController) { + private func hideNavigationBar(on navigationController: UINavigationController) { navigationController.setNavigationBarHidden(true, animated: false) - - let isRoot = navigationController.viewControllers.count <= 1 - navigationController.tabBarController?.tabBar.isHidden = !isRoot + // UIKit으로 탭바를 숨기면 하단 여백이 남을 수 있어, 탭바는 SwiftUI toolbar로만 제어한다. + if navigationController.viewControllers.count <= 1 { + navigationController.tabBarController?.tabBar.isHidden = false + } } } } + +extension View { + /// push된 화면에서 탭바를 숨기고 콘텐츠가 전체 높이를 쓰도록 한다. + func hidesTabBar() -> some View { + toolbar(.hidden, for: .tabBar) + } +} diff --git a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift index d580bbd..333dd6f 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift @@ -143,6 +143,7 @@ struct ArchiveDetailView: View { } } .animation(.easeOut(duration: 0.12), value: selectedNote?.id) + .hidesTabBar() } private func makeShredViewModel(for mode: CardShredMode) -> CardShredViewModel { diff --git a/GAMSS/Sources/Presentation/CardShred/CardShredView.swift b/GAMSS/Sources/Presentation/CardShred/CardShredView.swift index 847a443..3168c6e 100644 --- a/GAMSS/Sources/Presentation/CardShred/CardShredView.swift +++ b/GAMSS/Sources/Presentation/CardShred/CardShredView.swift @@ -56,6 +56,7 @@ struct CardShredView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.colorWhite) + .hidesTabBar() .alert( viewModel.alertMessage ?? "", isPresented: Binding( diff --git a/GAMSS/Sources/Presentation/Chat/ChatView.swift b/GAMSS/Sources/Presentation/Chat/ChatView.swift index b13a415..6cdec01 100644 --- a/GAMSS/Sources/Presentation/Chat/ChatView.swift +++ b/GAMSS/Sources/Presentation/Chat/ChatView.swift @@ -111,6 +111,7 @@ struct ChatView: View { } } .animation(.easeOut(duration: 0.12), value: viewModel.createdCard?.id) + .hidesTabBar() .onChange(of: viewModel.riskDetection) { _, newValue in if newValue != nil { isInputFocused = false } } diff --git a/GAMSS/Sources/Presentation/Root/MainTabView.swift b/GAMSS/Sources/Presentation/Root/MainTabView.swift index 3c3ce19..aacdfc6 100644 --- a/GAMSS/Sources/Presentation/Root/MainTabView.swift +++ b/GAMSS/Sources/Presentation/Root/MainTabView.swift @@ -75,7 +75,7 @@ struct MainTabView: View { } } - /// 탭별 NavigationStack. 네비게이션 바/탭 바 표시는 NavigationBarHider에서 처리한다. + /// 탭별 NavigationStack. 네비게이션 바 숨김은 NavigationBarHider에서 처리한다. private func tabNavigationStack(@ViewBuilder content: () -> Content) -> some View { NavigationStack { content() diff --git a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift index edbffde..650a8c3 100644 --- a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift +++ b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift @@ -87,6 +87,7 @@ struct AccountView: View { } } } + .hidesTabBar() } private var header: some View { diff --git a/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift b/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift index 27b03ff..88c4fe5 100644 --- a/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift +++ b/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift @@ -69,6 +69,7 @@ struct NicknameEditView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 18) .padding(.bottom, 12) + .hidesTabBar() } } diff --git a/GAMSS/Sources/Presentation/Setting/SettingView.swift b/GAMSS/Sources/Presentation/Setting/SettingView.swift index adad1c0..c33ce2b 100644 --- a/GAMSS/Sources/Presentation/Setting/SettingView.swift +++ b/GAMSS/Sources/Presentation/Setting/SettingView.swift @@ -32,6 +32,7 @@ struct SettingView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.colorWhite) + .hidesTabBar() .sheet(item: $presentedWebPage) { page in SafariView(url: page.url) .ignoresSafeArea() From b0ba3241f5cd28d1830d8bc159f03f48900b6060 Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Mon, 24 Aug 2026 23:37:14 +0900 Subject: [PATCH 06/11] =?UTF-8?q?[FIX]=20EmptyView=EC=9B=80=EC=B0=94?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Archive/Detail/ArchiveDetailView.swift | 1 - .../Presentation/Archive/Detail/ArchiveDetailViewModel.swift | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift index 333dd6f..4086155 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift @@ -106,7 +106,6 @@ struct ArchiveDetailView: View { onComplete: { shredMode = nil selectedNote = nil - viewModel.clearNotes() scene.clear() Task { await viewModel.load() } } diff --git a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailViewModel.swift b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailViewModel.swift index 593295a..5f5f873 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailViewModel.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailViewModel.swift @@ -12,7 +12,7 @@ import Foundation final class ArchiveDetailViewModel: ObservableObject { @Published var selectedMonth: Date @Published private(set) var notes: [DropNote] = [] - @Published private(set) var isLoading = false + @Published private(set) var isLoading = true @Published var errorMessage: String? private let fetchCardsByDateUseCase: FetchCardsByDateUseCase From 9440e41aa29203eaf53393a441426e9f1914bc84 Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Mon, 31 Aug 2026 10:51:59 +0900 Subject: [PATCH 07/11] =?UTF-8?q?[FIX]=20refreshToken=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EC=8B=9C=20MainTabView=EA=B0=87=ED=9E=88=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=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 --- GAMSS/Sources/Core/LoginSession.swift | 22 +++++++++ GAMSS/Sources/Core/LoginState.swift | 2 +- .../Sources/Core/Network/NetworkManager.swift | 43 ++++++++++++------ .../Repository/DefaultAuthRepository.swift | 20 ++++++++- .../Repository/DefaultMemberRepository.swift | 2 + GAMSS/Sources/Data/Storage/TokenStorage.swift | 10 +++-- GAMSS/Sources/Domain/Entity/AuthError.swift | 3 ++ .../Domain/Repository/AuthRepository.swift | 3 ++ .../UseCase/Auth/DefaultLoginUseCase.swift | 4 ++ .../Domain/UseCase/Auth/LoginUseCase.swift | 2 + .../Sources/Presentation/Home/HomeView.swift | 6 --- .../Sources/Presentation/Root/RootView.swift | 45 +++++++++++++------ .../Setting/Account/AccountView.swift | 2 +- .../DefaultCardRepositoryTests.swift | 6 ++- .../DefaultConversationRepositoryTests.swift | 6 ++- .../DefaultMemberRepositoryTests.swift | 6 ++- 16 files changed, 141 insertions(+), 41 deletions(-) create mode 100644 GAMSS/Sources/Core/LoginSession.swift diff --git a/GAMSS/Sources/Core/LoginSession.swift b/GAMSS/Sources/Core/LoginSession.swift new file mode 100644 index 0000000..3f1ac1e --- /dev/null +++ b/GAMSS/Sources/Core/LoginSession.swift @@ -0,0 +1,22 @@ +// +// LoginSession.swift +// GAMSS +// +// Created by 이건준 on 8/12/26. +// + +import Foundation + +@Observable +@MainActor +final class LoginSession { + static let shared = LoginSession() + + var value: LoginState = .current + + private init() {} + + func updateFromStorage() { + value = .current + } +} diff --git a/GAMSS/Sources/Core/LoginState.swift b/GAMSS/Sources/Core/LoginState.swift index 8d7a614..9352f3e 100644 --- a/GAMSS/Sources/Core/LoginState.swift +++ b/GAMSS/Sources/Core/LoginState.swift @@ -10,7 +10,7 @@ import Foundation enum LoginState { /// 로그인 안되어 있음 case notLoggedIn - /// 자동 로그인 설정이 되어있음, accessToken 갱신 필요 + /// refreshToken은 있으나 accessToken이 없어, 로그인 API로 세션 복구가 필요함 case autoLoginPending /// 로그인 되어있음 case loggedIn diff --git a/GAMSS/Sources/Core/Network/NetworkManager.swift b/GAMSS/Sources/Core/Network/NetworkManager.swift index 5e9951c..b4af719 100644 --- a/GAMSS/Sources/Core/Network/NetworkManager.swift +++ b/GAMSS/Sources/Core/Network/NetworkManager.swift @@ -5,15 +5,26 @@ // Created by 이건준 on 7/19/26. // +import FirebaseAuth import Foundation protocol NetworkRequesting { func request( _ endpoint: Endpoint, - responseType: T.Type + responseType: T.Type, + isRetryAfterReissue: Bool ) async throws -> T } +extension NetworkRequesting { + func request( + _ endpoint: Endpoint, + responseType: T.Type + ) async throws -> T { + try await request(endpoint, responseType: responseType, isRetryAfterReissue: false) + } +} + final class NetworkManager: NetworkRequesting { static let shared = NetworkManager() @@ -28,14 +39,7 @@ final class NetworkManager: NetworkRequesting { self.decoder = decoder } - func request( - _ endpoint: Endpoint, - responseType: T.Type - ) async throws -> T { - try await request(endpoint, responseType: responseType, isRetryAfterReissue: false) - } - - /// `isRetryAfterReissue`가 true면 이미 한 번 토큰을 재발급받고 재시도하는 중이라는 뜻 — + /// `isRetryAfterReissue`가 true면 이미 한 번 토큰을 재발급/자동로그인 후 재시도하는 중이라는 뜻 — /// 여기서 또 EXPIRED_TOKEN이 나도 다시 재발급을 시도하지 않는다(무한 루프 방지). func request( _ endpoint: Endpoint, @@ -70,12 +74,25 @@ final class NetworkManager: NetworkRequesting { do { try await TokenStorage.shared.reissueToken() } catch { - Log.error("Token reissue failed: \(error)") - throw NetworkError.expiredToken + Log.error("Token reissue failed, trying auto login: \(error)") + do { + try await DefaultAuthRepository( + networkManager: NetworkManager.shared, + tokenStorage: TokenStorage.shared + ).autoLogin() + } catch { + Log.error("Auto login after reissue failed: \(error)") + try? TokenStorage.shared.deleteTokens() + try? Auth.auth().signOut() + await MainActor.run { + LoginSession.shared.updateFromStorage() + } + throw NetworkError.expiredToken + } } - // 재발급된 토큰은 HttpHeader가 요청을 다시 만들 때 Keychain에서 새로 읽어오므로, - // 원래 요청을 그대로 한 번 더 시도하면 된다. + // 재발급/자동로그인으로 갱신된 토큰은 HttpHeader가 요청을 다시 만들 때 + // Keychain에서 새로 읽어오므로, 원래 요청을 그대로 한 번 더 시도하면 된다. return try await request(endpoint, responseType: responseType, isRetryAfterReissue: true) } diff --git a/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift b/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift index 3468d8d..be17725 100644 --- a/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift +++ b/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift @@ -24,6 +24,22 @@ final class DefaultAuthRepository: AuthRepository { func logout() async throws { _ = try await networkManager.request(AuthEndpoint.logout, responseType: APIResponse.self) try tokenStorage.deleteTokens() + try? Auth.auth().signOut() + } + + func autoLogin() async throws { + guard let user = Auth.auth().currentUser else { + throw AuthError.missingFirebaseUser + } + + let firebaseIdToken: String + do { + firebaseIdToken = try await user.getIDToken(forcingRefresh: true) + } catch { + throw AuthError.firebaseSignInFailed(error) + } + + try await login(firebaseIdToken: firebaseIdToken) } func login( @@ -89,9 +105,11 @@ final class DefaultAuthRepository: AuthRepository { private func login( firebaseIdToken: String ) async throws { + // 로그인/자동로그인 중 401이 나도 재발급→자동로그인 루프에 들어가지 않도록 한다. let response = try await networkManager.request( AuthEndpoint.login(.init(idToken: firebaseIdToken)), - responseType: APIResponse.self + responseType: APIResponse.self, + isRetryAfterReissue: true ) do { diff --git a/GAMSS/Sources/Data/Repository/DefaultMemberRepository.swift b/GAMSS/Sources/Data/Repository/DefaultMemberRepository.swift index 2a8e2ef..2457465 100644 --- a/GAMSS/Sources/Data/Repository/DefaultMemberRepository.swift +++ b/GAMSS/Sources/Data/Repository/DefaultMemberRepository.swift @@ -5,6 +5,7 @@ // Created by 이건준 on 8/13/26. // +import FirebaseAuth import Foundation final class DefaultMemberRepository: MemberRepository { @@ -19,6 +20,7 @@ final class DefaultMemberRepository: MemberRepository { func deleteMember() async throws { _ = try await networkManager.request(MemberEndpoint.deleteAccount, responseType: APIResponse.self) try tokenStorage.deleteTokens() + try? Auth.auth().signOut() } func fetchMyProfile() async throws -> User { diff --git a/GAMSS/Sources/Data/Storage/TokenStorage.swift b/GAMSS/Sources/Data/Storage/TokenStorage.swift index 1da8ed6..eff9c7b 100644 --- a/GAMSS/Sources/Data/Storage/TokenStorage.swift +++ b/GAMSS/Sources/Data/Storage/TokenStorage.swift @@ -14,11 +14,15 @@ final class TokenStorage { func reissueToken() async throws { guard let refreshToken = readToken(.refreshToken) else { - try TokenStorage.shared.deleteTokens() - return + try deleteTokens() + throw NetworkError.expiredToken } - let response = try await NetworkManager.shared.request(AuthEndpoint.reissueToken(.init(refreshToken: refreshToken)), responseType: APIResponse.self, isRetryAfterReissue: true).data + let response = try await NetworkManager.shared.request( + AuthEndpoint.reissueToken(.init(refreshToken: refreshToken)), + responseType: APIResponse.self, + isRetryAfterReissue: true + ).data try createTokens(accessToken: response.accessToken, refreshToken: response.refreshToken) } diff --git a/GAMSS/Sources/Domain/Entity/AuthError.swift b/GAMSS/Sources/Domain/Entity/AuthError.swift index b188697..00bfd91 100644 --- a/GAMSS/Sources/Domain/Entity/AuthError.swift +++ b/GAMSS/Sources/Domain/Entity/AuthError.swift @@ -17,6 +17,9 @@ enum AuthError: Error { /// Firebase 인증 실패 case firebaseSignInFailed(Error) + + /// 자동 로그인에 사용할 Firebase 세션이 없음 + case missingFirebaseUser /// 서버 로그인 실패 case serverLoginFailed(Error) diff --git a/GAMSS/Sources/Domain/Repository/AuthRepository.swift b/GAMSS/Sources/Domain/Repository/AuthRepository.swift index af10c86..e507677 100644 --- a/GAMSS/Sources/Domain/Repository/AuthRepository.swift +++ b/GAMSS/Sources/Domain/Repository/AuthRepository.swift @@ -13,6 +13,9 @@ protocol AuthRepository { credential: ASAuthorizationAppleIDCredential, nonce: String ) async throws + + /// Firebase에 남아 있는 세션으로 ID 토큰을 받아 로그인 API를 다시 호출한다. + func autoLogin() async throws func logout() async throws } diff --git a/GAMSS/Sources/Domain/UseCase/Auth/DefaultLoginUseCase.swift b/GAMSS/Sources/Domain/UseCase/Auth/DefaultLoginUseCase.swift index d4e250e..bf84a2f 100644 --- a/GAMSS/Sources/Domain/UseCase/Auth/DefaultLoginUseCase.swift +++ b/GAMSS/Sources/Domain/UseCase/Auth/DefaultLoginUseCase.swift @@ -25,4 +25,8 @@ final class DefaultLoginUseCase: LoginUseCase { nonce: nonce ) } + + func autoLogin() async throws { + try await authRepository.autoLogin() + } } diff --git a/GAMSS/Sources/Domain/UseCase/Auth/LoginUseCase.swift b/GAMSS/Sources/Domain/UseCase/Auth/LoginUseCase.swift index fee5162..25eb871 100644 --- a/GAMSS/Sources/Domain/UseCase/Auth/LoginUseCase.swift +++ b/GAMSS/Sources/Domain/UseCase/Auth/LoginUseCase.swift @@ -13,4 +13,6 @@ protocol LoginUseCase { credential: ASAuthorizationAppleIDCredential, nonce: String ) async throws + + func autoLogin() async throws } diff --git a/GAMSS/Sources/Presentation/Home/HomeView.swift b/GAMSS/Sources/Presentation/Home/HomeView.swift index b00a870..53fc27c 100644 --- a/GAMSS/Sources/Presentation/Home/HomeView.swift +++ b/GAMSS/Sources/Presentation/Home/HomeView.swift @@ -12,16 +12,10 @@ struct HomeView: View { @FocusState private var isInputFocused: Bool @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) -// do { -// try? TokenStorage.shared.deleteTokens() -// loginSession.value = .current -// } - } var body: some View { diff --git a/GAMSS/Sources/Presentation/Root/RootView.swift b/GAMSS/Sources/Presentation/Root/RootView.swift index 46cf639..79daa57 100644 --- a/GAMSS/Sources/Presentation/Root/RootView.swift +++ b/GAMSS/Sources/Presentation/Root/RootView.swift @@ -5,16 +5,19 @@ // Created by 이건준 on 8/12/26. // +import FirebaseAuth import SwiftUI -@Observable -final class LoginSession { - var value: LoginState = .current -} - struct RootView: View { - @State private var loginSession = LoginSession() + @State private var loginSession = LoginSession.shared @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false + + private let loginUseCase: LoginUseCase = DefaultLoginUseCase( + authRepository: DefaultAuthRepository( + networkManager: NetworkManager.shared, + tokenStorage: TokenStorage.shared + ) + ) var body: some View { ZStack { @@ -22,16 +25,19 @@ struct RootView: View { case .notLoggedIn: LoginView( viewModel: LoginViewModel( - loginUseCase: DefaultLoginUseCase( - authRepository: DefaultAuthRepository( - networkManager: NetworkManager.shared, - tokenStorage: TokenStorage.shared - ) - ) + loginUseCase: loginUseCase ) ) - case .autoLoginPending, .loggedIn: + case .autoLoginPending: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.colorWhite) + .task { + await performAutoLogin() + } + + case .loggedIn: if hasCompletedOnboarding { MainTabView() } else { @@ -55,6 +61,19 @@ struct RootView: View { .environment(loginSession) .environment(UserManager.shared) } + + /// Firebase 세션으로 로그인 API를 다시 호출해 자동 로그인을 완료한다. + private func performAutoLogin() async { + do { + try await loginUseCase.autoLogin() + loginSession.value = .loggedIn + } catch { + Log.error("Auto login failed: \(error)") + try? TokenStorage.shared.deleteTokens() + try? Auth.auth().signOut() + loginSession.value = .notLoggedIn + } + } } #Preview { diff --git a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift index 650a8c3..982c64e 100644 --- a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift +++ b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift @@ -171,7 +171,7 @@ struct AccountView: View { ) ) ) - .environment(LoginSession()) + .environment(LoginSession.shared) .environment(UserManager.shared) } } diff --git a/GAMSSTests/Data/Repository/DefaultCardRepositoryTests.swift b/GAMSSTests/Data/Repository/DefaultCardRepositoryTests.swift index f1c8243..310a76f 100644 --- a/GAMSSTests/Data/Repository/DefaultCardRepositoryTests.swift +++ b/GAMSSTests/Data/Repository/DefaultCardRepositoryTests.swift @@ -13,7 +13,11 @@ private final class MockNetworkRequesting: NetworkRequesting { var stubbedError: Error? private(set) var lastEndpoint: Endpoint? - func request(_ endpoint: Endpoint, responseType: T.Type) async throws -> T { + func request( + _ endpoint: Endpoint, + responseType: T.Type, + isRetryAfterReissue: Bool + ) async throws -> T { lastEndpoint = endpoint if let stubbedError { throw stubbedError } guard let stubbedData else { fatalError("stubbedData not set") } diff --git a/GAMSSTests/Data/Repository/DefaultConversationRepositoryTests.swift b/GAMSSTests/Data/Repository/DefaultConversationRepositoryTests.swift index 2ba08cc..8bec0c9 100644 --- a/GAMSSTests/Data/Repository/DefaultConversationRepositoryTests.swift +++ b/GAMSSTests/Data/Repository/DefaultConversationRepositoryTests.swift @@ -14,7 +14,11 @@ private final class MockNetworkRequesting: NetworkRequesting { var stubbedError: Error? private(set) var lastEndpoint: Endpoint? - func request(_ endpoint: Endpoint, responseType: T.Type) async throws -> T { + func request( + _ endpoint: Endpoint, + responseType: T.Type, + isRetryAfterReissue: Bool + ) async throws -> T { lastEndpoint = endpoint if let stubbedError { throw stubbedError } guard let stubbedData else { fatalError("stubbedData not set") } diff --git a/GAMSSTests/Data/Repository/DefaultMemberRepositoryTests.swift b/GAMSSTests/Data/Repository/DefaultMemberRepositoryTests.swift index 95fb9a7..1df67d4 100644 --- a/GAMSSTests/Data/Repository/DefaultMemberRepositoryTests.swift +++ b/GAMSSTests/Data/Repository/DefaultMemberRepositoryTests.swift @@ -13,7 +13,11 @@ private final class MockNetworkRequesting: NetworkRequesting { var stubbedError: Error? private(set) var lastEndpoint: Endpoint? - func request(_ endpoint: Endpoint, responseType: T.Type) async throws -> T { + func request( + _ endpoint: Endpoint, + responseType: T.Type, + isRetryAfterReissue: Bool + ) async throws -> T { lastEndpoint = endpoint if let stubbedError { throw stubbedError } guard let stubbedData else { fatalError("stubbedData not set") } From ecf05a1ace1424d486eef4e40053ccaa33256c58 Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Mon, 31 Aug 2026 11:09:05 +0900 Subject: [PATCH 08/11] =?UTF-8?q?[CHORE]=20=EA=B3=B5=EC=9A=A9=20Navigation?= =?UTF-8?q?BarView=EC=A0=81=EC=9A=A9=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Navigation/NavigationBarView.swift | 181 ++++++++++++++++++ GAMSS/Sources/Core/LoginState.swift | 2 +- .../Sources/Core/Network/NetworkManager.swift | 4 - .../Repository/DefaultAuthRepository.swift | 1 - GAMSS/Sources/Domain/Entity/AuthError.swift | 2 +- .../Domain/Repository/AuthRepository.swift | 1 - .../Presentation/Archive/ArchiveView.swift | 37 ++-- .../Archive/Detail/ArchiveDetailView.swift | 41 +--- .../CardShred/CardShredView.swift | 19 +- .../Sources/Presentation/Chat/ChatView.swift | 46 ++--- .../ConversationListHeaderView.swift | 45 ++--- .../ConversationListView.swift | 155 +++++++-------- .../Sources/Presentation/Home/HomeView.swift | 78 ++++---- .../Onboarding/OnboardingView.swift | 45 ++--- .../Sources/Presentation/Root/RootView.swift | 1 - .../Setting/Account/AccountView.swift | 19 +- .../NicknameEdit/NicknameEditView.swift | 113 +++++------ .../Presentation/Setting/SettingView.swift | 19 +- 18 files changed, 421 insertions(+), 388 deletions(-) create mode 100644 GAMSS/Sources/Core/Components/Navigation/NavigationBarView.swift diff --git a/GAMSS/Sources/Core/Components/Navigation/NavigationBarView.swift b/GAMSS/Sources/Core/Components/Navigation/NavigationBarView.swift new file mode 100644 index 0000000..99c89b2 --- /dev/null +++ b/GAMSS/Sources/Core/Components/Navigation/NavigationBarView.swift @@ -0,0 +1,181 @@ +// +// NavigationBarView.swift +// GAMSS +// +// Created by 이건준 on 8/31/26. +// + +import SwiftUI + +enum NavigationBarMetrics { + static let height: CGFloat = 64 + static let horizontalPadding: CGFloat = Spacing.spacing350 +} + +/// 시스템 NavigationBar 대신 쓰는 공용 상단 바. +struct NavigationBarView: View { + enum TitlePlacement { + case leading + case center + } + + enum LeadingContent { + case backButton + case logo + case none + } + + private let title: String? + private let titlePlacement: TitlePlacement + private let titleStyle: Typography + private let titleColor: Color + private let leadingContent: LeadingContent + private let onBack: () -> Void + private let horizontalPadding: CGFloat + private let trailing: Trailing + + init( + title: String? = nil, + titlePlacement: TitlePlacement = .leading, + titleStyle: Typography = .subtitle2, + titleColor: Color = .colorGray900, + leading: LeadingContent = .backButton, + onBack: @escaping () -> Void = {}, + horizontalPadding: CGFloat = NavigationBarMetrics.horizontalPadding, + @ViewBuilder trailing: () -> Trailing = { EmptyView() } + ) { + self.title = title + self.titlePlacement = titlePlacement + self.titleStyle = titleStyle + self.titleColor = titleColor + self.leadingContent = leading + self.onBack = onBack + self.horizontalPadding = horizontalPadding + self.trailing = trailing() + } + + init( + title: String? = nil, + titlePlacement: TitlePlacement = .leading, + titleStyle: Typography = .subtitle2, + titleColor: Color = .colorGray900, + showsBackButton: Bool, + onBack: @escaping () -> Void = {}, + horizontalPadding: CGFloat = NavigationBarMetrics.horizontalPadding, + @ViewBuilder trailing: () -> Trailing = { EmptyView() } + ) { + self.init( + title: title, + titlePlacement: titlePlacement, + titleStyle: titleStyle, + titleColor: titleColor, + leading: showsBackButton ? .backButton : .none, + onBack: onBack, + horizontalPadding: horizontalPadding, + trailing: trailing + ) + } + + var body: some View { + Group { + switch titlePlacement { + case .leading: + leadingLayout + case .center: + centerLayout + } + } + .frame(height: NavigationBarMetrics.height) + .padding(.horizontal, horizontalPadding) + } + + private var leadingLayout: some View { + HStack(spacing: Spacing.spacing200) { + leadingView + + if let title { + Text(title) + .typography(titleStyle) + .foregroundStyle(titleColor) + } + + Spacer(minLength: 0) + + trailing + } + } + + private var centerLayout: some View { + ZStack { + if let title { + Text(title) + .typography(titleStyle) + .foregroundStyle(titleColor) + } + + HStack(spacing: Spacing.spacing200) { + leadingView + + Spacer(minLength: 0) + + trailing + } + } + } + + @ViewBuilder + private var leadingView: some View { + switch leadingContent { + case .backButton: + Button(action: onBack) { + Image(systemName: "chevron.left") + .foregroundStyle(Color.colorGray900) + } + .accessibilityLabel("뒤로가기") + + case .logo: + Image("logoGamss") + .resizable() + .scaledToFit() + .frame(height: 24) + + case .none: + EmptyView() + } + } +} + +#Preview("Push") { + VStack { + NavigationBarView(title: "설정", onBack: {}) + NavigationBarView(title: "보관함", onBack: {}) { + Text("비우기") + .typography(.body5Medium) + .foregroundStyle(Color.colorGray900) + } + } +} + +#Preview("Tab logo") { + NavigationBarView(leading: .logo) { + Image("gear") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } +} + +#Preview("Center") { + NavigationBarView( + title: "2026.08.31", + titlePlacement: .center, + titleStyle: .subtitle3, + titleColor: .colorGray950, + onBack: {} + ) { + HStack(spacing: Spacing.spacing400) { + Image("iconCardGenerate") + Image("iconTokenUsage") + } + } +} diff --git a/GAMSS/Sources/Core/LoginState.swift b/GAMSS/Sources/Core/LoginState.swift index 9352f3e..b272fc6 100644 --- a/GAMSS/Sources/Core/LoginState.swift +++ b/GAMSS/Sources/Core/LoginState.swift @@ -10,7 +10,7 @@ import Foundation enum LoginState { /// 로그인 안되어 있음 case notLoggedIn - /// refreshToken은 있으나 accessToken이 없어, 로그인 API로 세션 복구가 필요함 + /// 자동 로그인 대기 case autoLoginPending /// 로그인 되어있음 case loggedIn diff --git a/GAMSS/Sources/Core/Network/NetworkManager.swift b/GAMSS/Sources/Core/Network/NetworkManager.swift index b4af719..744b931 100644 --- a/GAMSS/Sources/Core/Network/NetworkManager.swift +++ b/GAMSS/Sources/Core/Network/NetworkManager.swift @@ -39,8 +39,6 @@ final class NetworkManager: NetworkRequesting { self.decoder = decoder } - /// `isRetryAfterReissue`가 true면 이미 한 번 토큰을 재발급/자동로그인 후 재시도하는 중이라는 뜻 — - /// 여기서 또 EXPIRED_TOKEN이 나도 다시 재발급을 시도하지 않는다(무한 루프 방지). func request( _ endpoint: Endpoint, responseType: T.Type, @@ -91,8 +89,6 @@ final class NetworkManager: NetworkRequesting { } } - // 재발급/자동로그인으로 갱신된 토큰은 HttpHeader가 요청을 다시 만들 때 - // Keychain에서 새로 읽어오므로, 원래 요청을 그대로 한 번 더 시도하면 된다. return try await request(endpoint, responseType: responseType, isRetryAfterReissue: true) } diff --git a/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift b/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift index be17725..8a4035b 100644 --- a/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift +++ b/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift @@ -105,7 +105,6 @@ final class DefaultAuthRepository: AuthRepository { private func login( firebaseIdToken: String ) async throws { - // 로그인/자동로그인 중 401이 나도 재발급→자동로그인 루프에 들어가지 않도록 한다. let response = try await networkManager.request( AuthEndpoint.login(.init(idToken: firebaseIdToken)), responseType: APIResponse.self, diff --git a/GAMSS/Sources/Domain/Entity/AuthError.swift b/GAMSS/Sources/Domain/Entity/AuthError.swift index 00bfd91..7deac75 100644 --- a/GAMSS/Sources/Domain/Entity/AuthError.swift +++ b/GAMSS/Sources/Domain/Entity/AuthError.swift @@ -18,7 +18,7 @@ enum AuthError: Error { /// Firebase 인증 실패 case firebaseSignInFailed(Error) - /// 자동 로그인에 사용할 Firebase 세션이 없음 + /// Firebase 세션 없음 case missingFirebaseUser /// 서버 로그인 실패 diff --git a/GAMSS/Sources/Domain/Repository/AuthRepository.swift b/GAMSS/Sources/Domain/Repository/AuthRepository.swift index e507677..7e1c383 100644 --- a/GAMSS/Sources/Domain/Repository/AuthRepository.swift +++ b/GAMSS/Sources/Domain/Repository/AuthRepository.swift @@ -14,7 +14,6 @@ protocol AuthRepository { nonce: String ) async throws - /// Firebase에 남아 있는 세션으로 ID 토큰을 받아 로그인 API를 다시 호출한다. func autoLogin() async throws func logout() async throws diff --git a/GAMSS/Sources/Presentation/Archive/ArchiveView.swift b/GAMSS/Sources/Presentation/Archive/ArchiveView.swift index fee6c5b..d1fc93f 100644 --- a/GAMSS/Sources/Presentation/Archive/ArchiveView.swift +++ b/GAMSS/Sources/Presentation/Archive/ArchiveView.swift @@ -17,8 +17,18 @@ struct ArchiveView: View { var body: some View { VStack(spacing: 0) { - header - .padding(.bottom, 32) + NavigationBarView(leading: .logo) { + Button { + isSettingPresented = true + } label: { + Image("gear") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(Color.colorGray900) + } + } + .padding(.bottom, 32) ScrollView { VStack(alignment: .center, spacing: 34) { @@ -40,29 +50,6 @@ struct ArchiveView: View { } private extension ArchiveView { - var header: some View { - HStack { - Image("logoGamss") - .resizable() - .scaledToFit() - .frame(height: 24) - - Spacer() - - Button { - isSettingPresented = true - } label: { - Image("gear") - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundStyle(Color.colorGray900) - } - } - .frame(height: 64) - .padding(.horizontal, 18) - } - var trashCanGrid: some View { LazyVGrid( columns: [ diff --git a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift index 333dd6f..32721d0 100644 --- a/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift +++ b/GAMSS/Sources/Presentation/Archive/Detail/ArchiveDetailView.swift @@ -29,12 +29,18 @@ struct ArchiveDetailView: View { var body: some View { ZStack { VStack(spacing: 0) { - header - .padding(.horizontal, 18) - .padding(.vertical, Spacing.spacing400) + NavigationBarView(title: title, onBack: { dismiss() }) { + Button { + shredMode = .all + } label: { + Text("비우기") + .typography(.body5Medium) + .foregroundStyle(Color.colorGray900) + } + } monthSelector - .padding(.horizontal, 18) + .padding(.horizontal, Spacing.spacing350) .padding(.bottom, Spacing.spacing300) ZStack { @@ -161,33 +167,6 @@ struct ArchiveDetailView: View { } } - private var header: some View { - HStack(spacing: Spacing.spacing200) { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - .foregroundStyle(Color.colorGray900) - } - - Text(title) - .typography(.subtitle2) - .foregroundStyle(Color.colorGray900) - - Spacer() - - Button { - shredMode = .all - } label: { - Text("비우기") - .typography(.body5Medium) - .foregroundStyle(Color.colorGray900) - } - .typography(.body5Medium) - .foregroundStyle(Color.colorGray900) - } - } - private var monthSelector: some View { Button { isMonthPickerPresented = true diff --git a/GAMSS/Sources/Presentation/CardShred/CardShredView.swift b/GAMSS/Sources/Presentation/CardShred/CardShredView.swift index 3168c6e..f764002 100644 --- a/GAMSS/Sources/Presentation/CardShred/CardShredView.swift +++ b/GAMSS/Sources/Presentation/CardShred/CardShredView.swift @@ -39,9 +39,7 @@ struct CardShredView: View { var body: some View { VStack(spacing: 0) { - header - .padding(.horizontal, 18) - .padding(.vertical, Spacing.spacing400) + NavigationBarView(title: "비우기", onBack: onBack) powerToggle @@ -68,21 +66,6 @@ struct CardShredView: View { } } - private var header: some View { - HStack(spacing: Spacing.spacing200) { - Button(action: onBack) { - Image(systemName: "chevron.left") - .foregroundStyle(Color.colorGray900) - } - - Text("비우기") - .typography(.subtitle2) - .foregroundStyle(Color.colorGray900) - - Spacer() - } - } - private var powerToggle: some View { HStack { HStack(spacing: 0) { diff --git a/GAMSS/Sources/Presentation/Chat/ChatView.swift b/GAMSS/Sources/Presentation/Chat/ChatView.swift index 6cdec01..8e06446 100644 --- a/GAMSS/Sources/Presentation/Chat/ChatView.swift +++ b/GAMSS/Sources/Presentation/Chat/ChatView.swift @@ -252,40 +252,30 @@ struct ChatView: View { } } - /// 커스텀 상단 헤더: 뒤로가기 + 대화방 생성 날짜 + 우측 버튼 2개(종료, 토큰 사용량). - /// 시스템 네비게이션 바는 MainTabView의 NavigationStack에서 숨긴다. private var header: some View { - ZStack { - Text(headerDateText) - .typography(.subtitle3) - .foregroundStyle(Color.colorGray950) - - HStack { - Button(action: { dismiss() }) { - Image(systemName: "chevron.left") - .foregroundStyle(Color.colorGray950) + NavigationBarView( + title: headerDateText, + titlePlacement: .center, + titleStyle: .subtitle3, + titleColor: .colorGray950, + onBack: { dismiss() }, + horizontalPadding: Spacing.spacing400 + ) { + HStack(spacing: Spacing.spacing400) { + Button(action: { viewModel.requestEndConversation() }) { + Image("iconCardGenerate") } + .disabled(!viewModel.canEndConversation) + .accessibilityLabel("대화 종료") - Spacer() - - HStack(spacing: Spacing.spacing400) { - Button(action: { viewModel.requestEndConversation() }) { - Image("iconCardGenerate") - } - .disabled(!viewModel.canEndConversation) - .accessibilityLabel("대화 종료") - - Button(action: { - viewModel.isTokenUsagePopoverPresented.toggle() - }) { - Image("iconTokenUsage") - } - .accessibilityLabel("토큰 사용량") + Button(action: { + viewModel.isTokenUsagePopoverPresented.toggle() + }) { + Image("iconTokenUsage") } + .accessibilityLabel("토큰 사용량") } } - .padding(.horizontal, Spacing.spacing400) - .padding(.vertical, Spacing.spacing400) .background(Color.colorWhite) } diff --git a/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift b/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift index c1039b6..67d42e1 100644 --- a/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/Components/ConversationListHeaderView.swift @@ -14,38 +14,29 @@ struct ConversationListHeaderView: View { var onTappedSettingButton: (() -> Void)? var body: some View { - HStack(spacing: Spacing.spacing200) { - switch currentMode { - case .normal: - Image("logoGamss") - .resizable() - .scaledToFit() - .frame(height: 24) - case .delete: + NavigationBarView( + leading: currentMode == .normal ? .logo : .backButton, + onBack: { onTappedBackButton?() } + ) { + HStack(spacing: Spacing.spacing200) { Button { - onTappedBackButton?() + onTappedSearchButton?() } label: { - Image(systemName: "chevron.left") + Image(systemName: "magnifyingglass") + .frame(width: 24, height: 24) + .foregroundStyle(Color.colorGray900) + } + + Button { + onTappedSettingButton?() + } label: { + Image("gear") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) .foregroundStyle(Color.colorGray900) } - } - - Spacer() - - Button { - onTappedSearchButton?() - } label: { - Image(systemName: "magnifyingglass") - .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 633daf5..9fbab8d 100644 --- a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift @@ -32,95 +32,96 @@ struct ConversationListView: View { }, onTappedSettingButton: { isSettingPresented = true }) - .frame(height: 64) - if viewModel.isSearching { - ConversationSearchView(onTappedCancelButton: { - withAnimation(.easeInOut(duration: 0.3)) { - viewModel.stopSearching() - } - }, onTappedSearchButton: { - Task { - await viewModel.searchText() - } - }, editingText: $viewModel.editedText) - .frame(height: 42) - .transition( - .move(edge: .top) - .combined(with: .opacity) - ) - } - - dateHeader.padding(.vertical, Spacing.spacing150) - - if !viewModel.isLoading && viewModel.displayedConversations.isEmpty { - ConversationListEmptyView() - } else { - ScrollView { - LazyVStack(spacing: Spacing.spacing100) { - ForEach(viewModel.displayedConversations) { conversation in - switch viewModel.currentMode { - case .normal: - Button { - selectedConversation = conversation - } label: { - ConversationRowView( - currentMode: viewModel.currentMode, - conversation: conversation, - isSelected: false - ) - } - .buttonStyle(.plain) - - case .delete: - Button { - viewModel.selectConversation(id: conversation.id) - } label: { - ConversationRowView( - currentMode: viewModel.currentMode, - conversation: conversation, - isSelected: viewModel.isSelected( - id: conversation.id + Group { + if viewModel.isSearching { + ConversationSearchView(onTappedCancelButton: { + withAnimation(.easeInOut(duration: 0.3)) { + viewModel.stopSearching() + } + }, onTappedSearchButton: { + Task { + await viewModel.searchText() + } + }, editingText: $viewModel.editedText) + .frame(height: 42) + .transition( + .move(edge: .top) + .combined(with: .opacity) + ) + } + + dateHeader.padding(.vertical, Spacing.spacing150) + + if !viewModel.isLoading && viewModel.displayedConversations.isEmpty { + ConversationListEmptyView() + } else { + ScrollView { + LazyVStack(spacing: Spacing.spacing100) { + ForEach(viewModel.displayedConversations) { conversation in + switch viewModel.currentMode { + case .normal: + Button { + selectedConversation = conversation + } label: { + ConversationRowView( + currentMode: viewModel.currentMode, + conversation: conversation, + isSelected: false + ) + } + .buttonStyle(.plain) + + case .delete: + Button { + viewModel.selectConversation(id: conversation.id) + } label: { + ConversationRowView( + currentMode: viewModel.currentMode, + conversation: conversation, + isSelected: viewModel.isSelected( + id: conversation.id + ) ) - ) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } } - } - Spacer() - if viewModel.currentMode == .delete { - Button { - Task { - await viewModel.deleteConversations() + Spacer() + if viewModel.currentMode == .delete { + Button { + Task { + await viewModel.deleteConversations() + } + } label: { + Text("삭제하기") + .typography(.body3Medium) + .foregroundStyle( + viewModel.isDeleteButtonEnabled + ? Color.colorWhite + : Color.colorGray300 + ) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background( + viewModel.isDeleteButtonEnabled + ? Color.colorRed + : Color.colorGray075 + ) + .clipShape( + RoundedRectangle(cornerRadius: 12) + ) } - } label: { - Text("삭제하기") - .typography(.body3Medium) - .foregroundStyle( - viewModel.isDeleteButtonEnabled - ? Color.colorWhite - : Color.colorGray300 - ) - .frame(maxWidth: .infinity) - .frame(height: 52) - .background( - viewModel.isDeleteButtonEnabled - ? Color.colorRed - : Color.colorGray075 - ) - .clipShape( - RoundedRectangle(cornerRadius: 12) - ) + .padding(.bottom, 10) + .disabled(!viewModel.isDeleteButtonEnabled) } - .padding(.bottom, 10) - .disabled(!viewModel.isDeleteButtonEnabled) } } + .padding(.horizontal, NavigationBarMetrics.horizontalPadding) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.horizontal, Spacing.spacing400) .navigationDestination(item: $selectedConversation) { conversation in ChatView( viewModel: ChatViewModel( diff --git a/GAMSS/Sources/Presentation/Home/HomeView.swift b/GAMSS/Sources/Presentation/Home/HomeView.swift index 53fc27c..b0faaf4 100644 --- a/GAMSS/Sources/Presentation/Home/HomeView.swift +++ b/GAMSS/Sources/Presentation/Home/HomeView.swift @@ -42,29 +42,43 @@ struct HomeView: View { } VStack(alignment: .leading, spacing: 0) { - header - .padding(.bottom, 188) // Figma 지정값 — 로고와 인사말 사이 간격 - - greeting - .padding(.bottom, Spacing.spacing500) - .opacity(isGreetingReady ? 1 : 0) - - MessageComposerView( - input: $viewModel.input, - selectedEmotions: viewModel.selectedEmotions, - isEmotionPickerOpen: $viewModel.isEmotionPickerOpen, - isSendDisabled: viewModel.isSendDisabled, - onToggleEmotion: { viewModel.toggleEmotion($0) }, - onCommit: { viewModel.send() }, - onInputChange: { viewModel.updateInput($0) }, - isFocused: $isInputFocused, - isDisabled: viewModel.isTokenExceeded, - disabledPlaceholder: viewModel.composerDisabledPlaceholder - ) + NavigationBarView(leading: .logo) { + Button { + isInputFocused = false + isSettingPresented = true + } label: { + Image("gear") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(Color.colorGray900) + } + } + .padding(.bottom, 188 - (NavigationBarMetrics.height - 24) / 2) + + VStack(alignment: .leading, spacing: 0) { + greeting + .padding(.bottom, Spacing.spacing500) + .opacity(isGreetingReady ? 1 : 0) + + MessageComposerView( + input: $viewModel.input, + selectedEmotions: viewModel.selectedEmotions, + isEmotionPickerOpen: $viewModel.isEmotionPickerOpen, + isSendDisabled: viewModel.isSendDisabled, + onToggleEmotion: { viewModel.toggleEmotion($0) }, + onCommit: { viewModel.send() }, + onInputChange: { viewModel.updateInput($0) }, + isFocused: $isInputFocused, + isDisabled: viewModel.isTokenExceeded, + disabledPlaceholder: viewModel.composerDisabledPlaceholder + ) - Spacer() + Spacer() + } + .padding(.horizontal, NavigationBarMetrics.horizontalPadding) + .padding(.bottom, Spacing.spacing400) } - .padding(Spacing.spacing400) } // 키보드가 올라오면 SwiftUI가 기본적으로 사용 가능한 영역을 줄이는데, 장식 // 이미지가 GeometryReader의 상대 좌표(geo.size)로 위치를 잡고 있어서 그 영역이 @@ -111,28 +125,6 @@ struct HomeView: View { } } - private var header: some View { - HStack { - Image("logoGamss") - .resizable() - .scaledToFit() - .frame(height: 24) - - Spacer() - - Button { - isInputFocused = false - isSettingPresented = true - } label: { - Image("gear") - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundStyle(Color.colorGray900) - } - } - } - /// "{닉네임}님 오늘도" / "감쓰에 버려볼까요?" — 닉네임 부분만 분홍 배경으로 하이라이트한다. /// UserManager에 값이 아직 없으면(조회 전/실패) fallback을 쓴다. private var greeting: some View { diff --git a/GAMSS/Sources/Presentation/Onboarding/OnboardingView.swift b/GAMSS/Sources/Presentation/Onboarding/OnboardingView.swift index c583163..faca68c 100644 --- a/GAMSS/Sources/Presentation/Onboarding/OnboardingView.swift +++ b/GAMSS/Sources/Presentation/Onboarding/OnboardingView.swift @@ -14,9 +14,20 @@ struct OnboardingView: View { var body: some View { VStack(spacing: 0) { - header - .padding(.horizontal, 18) - .padding(.vertical, Spacing.spacing400) + NavigationBarView( + showsBackButton: currentPage.showsBackButton, + onBack: { goToPreviousPage() } + ) { + if currentPage.showsSkipButton { + Button { + onFinish() + } label: { + Text("건너뛰기") + .typography(.body5Medium) + .foregroundStyle(Color.colorGray500) + } + } + } Spacer() @@ -30,7 +41,7 @@ struct OnboardingView: View { .padding(.bottom, Spacing.spacing400) actionButton - .padding(.horizontal, 18) + .padding(.horizontal, Spacing.spacing350) .padding(.bottom, Spacing.spacing400) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -38,32 +49,6 @@ struct OnboardingView: View { .animation(.easeInOut(duration: 0.25), value: currentPage) } - private var header: some View { - HStack { - if currentPage.showsBackButton { - Button { - goToPreviousPage() - } label: { - Image(systemName: "chevron.left") - .foregroundStyle(Color.colorGray900) - } - } - - Spacer() - - if currentPage.showsSkipButton { - Button { - onFinish() - } label: { - Text("건너뛰기") - .typography(.body5Medium) - .foregroundStyle(Color.colorGray500) - } - } - } - .frame(height: 24) - } - private func pageContent(_ page: OnboardingPage) -> some View { VStack(spacing: Spacing.spacing600) { page.backgroundImage diff --git a/GAMSS/Sources/Presentation/Root/RootView.swift b/GAMSS/Sources/Presentation/Root/RootView.swift index 79daa57..1e28d78 100644 --- a/GAMSS/Sources/Presentation/Root/RootView.swift +++ b/GAMSS/Sources/Presentation/Root/RootView.swift @@ -62,7 +62,6 @@ struct RootView: View { .environment(UserManager.shared) } - /// Firebase 세션으로 로그인 API를 다시 호출해 자동 로그인을 완료한다. private func performAutoLogin() async { do { try await loginUseCase.autoLogin() diff --git a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift index 982c64e..1b1ee86 100644 --- a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift +++ b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift @@ -25,9 +25,7 @@ struct AccountView: View { var body: some View { ZStack { VStack(alignment: .leading, spacing: 0) { - header - .padding(.horizontal, 18) - .padding(.vertical, Spacing.spacing400) + NavigationBarView(title: title, onBack: { dismiss() }) ForEach(AccountItem.allCases) { item in accountItem(item) @@ -90,21 +88,6 @@ struct AccountView: View { .hidesTabBar() } - private var header: some View { - HStack(spacing: 12) { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - .foregroundStyle(Color.colorGray900) - } - Text(title) - .typography(.subtitle2) - .foregroundStyle(Color.colorGray900) - Spacer() - } - } - @ViewBuilder private func accountItem(_ item: AccountItem) -> some View { let row = MenuListItemView( diff --git a/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift b/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift index 88c4fe5..b33f8b4 100644 --- a/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift +++ b/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift @@ -16,75 +16,60 @@ struct NicknameEditView: View { } var body: some View { - VStack(alignment: .leading) { - navigationView - .padding(.vertical, Spacing.spacing400) - .frame(height: 64) - Spacer().frame(height: 16) - Text("변경할 닉네임을 입력해주세요.") - .padding(.bottom, 12) - TextField(text: $viewModel.editingNickname) { - Text("닉네임은 2~20자 사이로 입력해주세요.") - .typography(.body3Medium) - .foregroundStyle(Color.colorGray400) - } - .padding(.vertical, 14) - .padding(.horizontal, 16) - .frame(height: 52) - .overlay( - Rectangle() - .strokeBorder(Color.colorGray950, lineWidth: 1) - ) - - Spacer() - - Button { - Task { - let success = await viewModel.updateNickname() - if success { - dismiss() + VStack(alignment: .leading, spacing: 0) { + NavigationBarView(title: "닉네임 변경", onBack: { dismiss() }) + + VStack(alignment: .leading, spacing: 0) { + Spacer().frame(height: 16) + Text("변경할 닉네임을 입력해주세요.") + .padding(.bottom, 12) + TextField(text: $viewModel.editingNickname) { + Text("닉네임은 2~20자 사이로 입력해주세요.") + .typography(.body3Medium) + .foregroundStyle(Color.colorGray400) + } + .padding(.vertical, 14) + .padding(.horizontal, 16) + .frame(height: 52) + .overlay( + Rectangle() + .strokeBorder(Color.colorGray950, lineWidth: 1) + ) + + Spacer() + + Button { + Task { + let success = await viewModel.updateNickname() + if success { + dismiss() + } } + } label: { + Text("저장하기") + .typography(.title5) + .foregroundStyle( + viewModel.isEnabledSaveButton + ? Color.colorWhite + : Color.colorGray300 + ) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background( + viewModel.isEnabledSaveButton + ? Color.colorGray950 + : Color.colorGray075 + ) + .clipShape( + RoundedRectangle(cornerRadius: 12) + ) } - } label: { - Text("저장하기") - .typography(.title5) - .foregroundStyle( - viewModel.isEnabledSaveButton - ? Color.colorWhite - : Color.colorGray300 - ) - .frame(maxWidth: .infinity) - .frame(height: 52) - .background( - viewModel.isEnabledSaveButton - ? Color.colorGray950 - : Color.colorGray075 - ) - .clipShape( - RoundedRectangle(cornerRadius: 12) - ) + .disabled(!viewModel.isEnabledSaveButton) } - .disabled(!viewModel.isEnabledSaveButton) + .padding(.horizontal, Spacing.spacing350) + .padding(.bottom, 12) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 18) - .padding(.bottom, 12) .hidesTabBar() } } - -extension NicknameEditView { - private var navigationView: some View { - HStack(spacing: 12) { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - .foregroundStyle(Color.colorGray900) - } - Text("닉네임 변경") - .typography(.subtitle2) - .foregroundStyle(Color.colorGray900) - } - } -} diff --git a/GAMSS/Sources/Presentation/Setting/SettingView.swift b/GAMSS/Sources/Presentation/Setting/SettingView.swift index c33ce2b..9a405cb 100644 --- a/GAMSS/Sources/Presentation/Setting/SettingView.swift +++ b/GAMSS/Sources/Presentation/Setting/SettingView.swift @@ -13,9 +13,7 @@ struct SettingView: View { var body: some View { VStack { - header - .padding(.horizontal, 18) - .padding(.vertical, Spacing.spacing400) + NavigationBarView(title: "설정", onBack: { dismiss() }) ForEach(SettingSection.allCases) { section in ForEach(section.items) { item in @@ -39,21 +37,6 @@ struct SettingView: View { } } - private var header: some View { - HStack(spacing: 12) { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - .foregroundStyle(Color.colorGray900) - } - Text("설정") - .typography(.subtitle2) - .foregroundStyle(Color.colorGray900) - Spacer() - } - } - private var fullWidthDivider: some View { Color.colorGray075 .frame(height: 2) From 6ebf726d87094fd6edbd744119c4bd1763355d4d Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Mon, 31 Aug 2026 11:41:21 +0900 Subject: [PATCH 09/11] =?UTF-8?q?[FEAT]=20=EA=B2=80=EC=83=89=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EA=B4=80=EB=A0=A8=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Response/SearchChatResponseDTO.swift | 18 ++ .../DefaultConversationRepository.swift | 10 +- .../Conversation/ConversationPage.swift | 19 ++ .../Conversation/ConversationRepository.swift | 2 +- .../DefaultSearchConversationUseCase.swift | 13 +- .../SearchConversationUseCase.swift | 2 +- .../ConversationListView.swift | 243 ++++++++++-------- .../ConversationListViewModel.swift | 44 +++- 8 files changed, 223 insertions(+), 128 deletions(-) create mode 100644 GAMSS/Sources/Domain/Conversation/ConversationPage.swift diff --git a/GAMSS/Sources/Data/DTO/Chat/Response/SearchChatResponseDTO.swift b/GAMSS/Sources/Data/DTO/Chat/Response/SearchChatResponseDTO.swift index eedf103..b7dd58a 100644 --- a/GAMSS/Sources/Data/DTO/Chat/Response/SearchChatResponseDTO.swift +++ b/GAMSS/Sources/Data/DTO/Chat/Response/SearchChatResponseDTO.swift @@ -13,6 +13,15 @@ struct SearchChatResponseDTO: Decodable { let size: Int let totalElements: Int let totalPages: Int + + func toDomain() -> ConversationPage { + ConversationPage( + items: content.compactMap { $0.toDomain() }, + page: page, + size: size, + totalPages: totalPages + ) + } } struct SearchContent: Decodable { @@ -20,4 +29,13 @@ struct SearchContent: Decodable { let title: String let status: String let createdAt: String + + func toDomain() -> ConversationSummary? { + guard let createdAtDate = ISO8601FlexibleParser.date(from: createdAt) else { return nil } + return ConversationSummary( + id: conversationId, + title: title, + createdAt: createdAtDate + ) + } } diff --git a/GAMSS/Sources/Data/Repository/DefaultConversationRepository.swift b/GAMSS/Sources/Data/Repository/DefaultConversationRepository.swift index 5c85636..40447b1 100644 --- a/GAMSS/Sources/Data/Repository/DefaultConversationRepository.swift +++ b/GAMSS/Sources/Data/Repository/DefaultConversationRepository.swift @@ -64,14 +64,14 @@ final class DefaultConversationRepository: ConversationRepository { ) } - func searchConversations(_ text: String) async throws -> SearchChatResponseDTO { - return try await networkManager.request( + func searchConversations(_ text: String, page: Int, size: Int) async throws -> ConversationPage { + try await networkManager.request( ChatEndpoint.searchChats( keyword: text, - page: 0, - size: 20 + page: page, + size: size ), responseType: APIResponse.self - ).data + ).data.toDomain() } } diff --git a/GAMSS/Sources/Domain/Conversation/ConversationPage.swift b/GAMSS/Sources/Domain/Conversation/ConversationPage.swift new file mode 100644 index 0000000..27772ab --- /dev/null +++ b/GAMSS/Sources/Domain/Conversation/ConversationPage.swift @@ -0,0 +1,19 @@ +// +// ConversationPage.swift +// GAMSS +// +// Created by 이건준 on 8/31/26. +// + +import Foundation + +struct ConversationPage { + let items: [ConversationSummary] + let page: Int + let size: Int + let totalPages: Int + + var hasNextPage: Bool { + page + 1 < totalPages + } +} diff --git a/GAMSS/Sources/Domain/Conversation/ConversationRepository.swift b/GAMSS/Sources/Domain/Conversation/ConversationRepository.swift index 8db631e..00e1bc3 100644 --- a/GAMSS/Sources/Domain/Conversation/ConversationRepository.swift +++ b/GAMSS/Sources/Domain/Conversation/ConversationRepository.swift @@ -25,5 +25,5 @@ protocol ConversationRepository { func endConversation(conversationId: Int) async throws func deleteConversations(_ ids: [Int]) async throws - func searchConversations(_ text: String) async throws -> SearchChatResponseDTO + func searchConversations(_ text: String, page: Int, size: Int) async throws -> ConversationPage } diff --git a/GAMSS/Sources/Domain/UseCase/Conversation/DefaultSearchConversationUseCase.swift b/GAMSS/Sources/Domain/UseCase/Conversation/DefaultSearchConversationUseCase.swift index 05bd681..538126d 100644 --- a/GAMSS/Sources/Domain/UseCase/Conversation/DefaultSearchConversationUseCase.swift +++ b/GAMSS/Sources/Domain/UseCase/Conversation/DefaultSearchConversationUseCase.swift @@ -14,16 +14,15 @@ struct DefaultSearchConversationUseCase: SearchConversationUseCase { self.conversationRepository = conversationRepository } - func execute(_ text: String) async throws -> [ConversationSummary] { + func execute(_ text: String, page: Int, size: Int) async throws -> ConversationPage { guard text.count >= 2 else { throw ConversationError.invalidSearchKeyword } - let data = try await conversationRepository.searchConversations(text) - return data.content.map { ConversationSummary( - id: $0.conversationId, - title: $0.title, - createdAt: Date() - ) } + return try await conversationRepository.searchConversations( + text, + page: page, + size: size + ) } } diff --git a/GAMSS/Sources/Domain/UseCase/Conversation/SearchConversationUseCase.swift b/GAMSS/Sources/Domain/UseCase/Conversation/SearchConversationUseCase.swift index cc5082c..afdc14f 100644 --- a/GAMSS/Sources/Domain/UseCase/Conversation/SearchConversationUseCase.swift +++ b/GAMSS/Sources/Domain/UseCase/Conversation/SearchConversationUseCase.swift @@ -8,5 +8,5 @@ import Foundation protocol SearchConversationUseCase { - func execute(_ text: String) async throws -> [ConversationSummary] + func execute(_ text: String, page: Int, size: Int) async throws -> ConversationPage } diff --git a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift index 9fbab8d..681a454 100644 --- a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift @@ -22,128 +22,118 @@ struct ConversationListView: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { - ConversationListHeaderView(currentMode: viewModel.currentMode, onTappedBackButton: { - viewModel.updateMode(.normal) - }, onTappedSearchButton: { + VStack(alignment: .leading, spacing: 0) { + ConversationListHeaderView( + currentMode: viewModel.currentMode, + onTappedBackButton: { viewModel.updateMode(.normal) }, + onTappedSearchButton: { withAnimation(.easeInOut(duration: 0.3)) { viewModel.startSearching() } - }, onTappedSettingButton: { - isSettingPresented = true - }) - - Group { - if viewModel.isSearching { - ConversationSearchView(onTappedCancelButton: { - withAnimation(.easeInOut(duration: 0.3)) { - viewModel.stopSearching() - } - }, onTappedSearchButton: { - Task { - await viewModel.searchText() - } - }, editingText: $viewModel.editedText) - .frame(height: 42) - .transition( - .move(edge: .top) - .combined(with: .opacity) - ) - } - - dateHeader.padding(.vertical, Spacing.spacing150) - - if !viewModel.isLoading && viewModel.displayedConversations.isEmpty { - ConversationListEmptyView() - } else { - ScrollView { - LazyVStack(spacing: Spacing.spacing100) { - ForEach(viewModel.displayedConversations) { conversation in - switch viewModel.currentMode { - case .normal: - Button { - selectedConversation = conversation - } label: { - ConversationRowView( - currentMode: viewModel.currentMode, - conversation: conversation, - isSelected: false - ) - } - .buttonStyle(.plain) - - case .delete: - Button { - viewModel.selectConversation(id: conversation.id) - } label: { - ConversationRowView( - currentMode: viewModel.currentMode, - conversation: conversation, - isSelected: viewModel.isSelected( - id: conversation.id - ) - ) + }, + onTappedSettingButton: { isSettingPresented = true } + ) + + if viewModel.isSearching { + ConversationSearchView( + onTappedCancelButton: { + withAnimation(.easeInOut(duration: 0.3)) { + viewModel.stopSearching() + } + }, + onTappedSearchButton: { + Task { await viewModel.searchText() } + }, + editingText: $viewModel.editedText + ) + .frame(height: 42) + .padding(.horizontal, NavigationBarMetrics.horizontalPadding) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + dateHeader + .padding(.vertical, Spacing.spacing150) + .padding(.horizontal, NavigationBarMetrics.horizontalPadding) + + if !viewModel.isLoading && viewModel.displayedConversations.isEmpty { + ConversationListEmptyView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, NavigationBarMetrics.horizontalPadding) + } else { + VStack(spacing: 0) { + ScrollView { + LazyVStack(spacing: Spacing.spacing100) { + ForEach(Array(viewModel.displayedConversations.enumerated()), id: \.element.id) { index, conversation in + conversationRow(for: conversation) + .onAppear { + Task { + await viewModel.loadMoreIfNeeded(at: index) } - .buttonStyle(.plain) } - } } - } - Spacer() - if viewModel.currentMode == .delete { - Button { - Task { - await viewModel.deleteConversations() - } - } label: { - Text("삭제하기") - .typography(.body3Medium) - .foregroundStyle( - viewModel.isDeleteButtonEnabled - ? Color.colorWhite - : Color.colorGray300 - ) + + if viewModel.isLoading && !viewModel.displayedConversations.isEmpty { + ProgressView() .frame(maxWidth: .infinity) - .frame(height: 52) - .background( - viewModel.isDeleteButtonEnabled - ? Color.colorRed - : Color.colorGray075 - ) - .clipShape( - RoundedRectangle(cornerRadius: 12) - ) + .padding(.vertical, Spacing.spacing200) } - .padding(.bottom, 10) - .disabled(!viewModel.isDeleteButtonEnabled) } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if viewModel.currentMode == .delete { + Button { + Task { await viewModel.deleteConversations() } + } label: { + Text("삭제하기") + .typography(.body3Medium) + .foregroundStyle( + viewModel.isDeleteButtonEnabled + ? Color.colorWhite + : Color.colorGray300 + ) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background( + viewModel.isDeleteButtonEnabled + ? Color.colorRed + : Color.colorGray075 + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.top, Spacing.spacing200) + .padding(.bottom, 10) + .disabled(!viewModel.isDeleteButtonEnabled) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .padding(.horizontal, NavigationBarMetrics.horizontalPadding) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .navigationDestination(item: $selectedConversation) { conversation in - ChatView( - viewModel: ChatViewModel( - sendMessageUseCase: SendMessageUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), - getMessagesUseCase: GetMessagesUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), - endConversationUseCase: EndConversationUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), - createCardUseCase: CreateCardUseCase(cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared)), - getTokenUsageUseCase: GetTokenUsageUseCase(memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared)), - updateConversationTitleUseCase: UpdateConversationTitleUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), - detectRiskInTextUseCase: DetectRiskInTextUseCase(repository: DefaultRiskLexiconRepository()), - summaryStore: LazyConversationSummaryStore(), - conversationId: conversation.id, - initialDate: conversation.createdAt - ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color.colorWhite) + .navigationDestination(item: $selectedConversation) { conversation in + ChatView( + viewModel: ChatViewModel( + sendMessageUseCase: SendMessageUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), + getMessagesUseCase: GetMessagesUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), + endConversationUseCase: EndConversationUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), + createCardUseCase: CreateCardUseCase(cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared)), + getTokenUsageUseCase: GetTokenUsageUseCase(memberRepository: DefaultMemberRepository(networkManager: NetworkManager.shared, tokenStorage: .shared)), + updateConversationTitleUseCase: UpdateConversationTitleUseCase(conversationRepository: DefaultConversationRepository(networkManager: NetworkManager.shared)), + detectRiskInTextUseCase: DetectRiskInTextUseCase(repository: DefaultRiskLexiconRepository()), + summaryStore: LazyConversationSummaryStore(), + conversationId: conversation.id, + initialDate: conversation.createdAt ) - } - .navigationDestination(isPresented: $isSettingPresented) { - SettingView() - } - .onAppear { - Task { await viewModel.load() } - } + ) + } + .navigationDestination(isPresented: $isSettingPresented) { + SettingView() + } + .onAppear { + Task { await viewModel.load() } + } .alert(viewModel.alertMessage ?? "", isPresented: Binding( get: { viewModel.alertMessage != nil }, set: { if !$0 { viewModel.alertMessage = nil } } @@ -151,17 +141,15 @@ struct ConversationListView: View { Button("확인", role: .cancel) {} } } - - /// 오늘 날짜를 표시만 한다 — 목록이 "미완료 대화방"(fetchIncompleteChats) 기준이라 실제로는 - /// 여러 날짜에 걸친 대화가 섞여 있을 수 있지만, 이 헤더는 조회 조건과 무관하게 항상 오늘 날짜 보여줌. + private var dateHeader: some View { HStack { Text(ConversationListDateHeaderFormatter.string(from: Date())) .typography(.body5Regular) .foregroundStyle(Color.colorGray950) - + Spacer() - + Button { viewModel.updateMode(.delete) } label: { @@ -171,4 +159,33 @@ struct ConversationListView: View { } } } + + @ViewBuilder + private func conversationRow(for conversation: ConversationSummary) -> some View { + switch viewModel.currentMode { + case .normal: + Button { + selectedConversation = conversation + } label: { + ConversationRowView( + currentMode: viewModel.currentMode, + conversation: conversation, + isSelected: false + ) + } + .buttonStyle(.plain) + + case .delete: + Button { + viewModel.selectConversation(id: conversation.id) + } label: { + ConversationRowView( + currentMode: viewModel.currentMode, + conversation: conversation, + isSelected: viewModel.isSelected(id: conversation.id) + ) + } + .buttonStyle(.plain) + } + } } diff --git a/GAMSS/Sources/Presentation/ConversationList/ConversationListViewModel.swift b/GAMSS/Sources/Presentation/ConversationList/ConversationListViewModel.swift index 06463ae..10cb948 100644 --- a/GAMSS/Sources/Presentation/ConversationList/ConversationListViewModel.swift +++ b/GAMSS/Sources/Presentation/ConversationList/ConversationListViewModel.swift @@ -30,6 +30,12 @@ final class ConversationListViewModel: ObservableObject { !selectedConversations.isEmpty } + private let pageSize = 10 + private let loadMorePrefetchCount = 5 + private var searchPage = 0 + private var hasMoreSearchResults = false + private var searchQuery = "" + private let getIncompleteConversationsUseCase: GetIncompleteConversationsUseCase private let deleteConversationsUseCase: DeleteConversationsUseCase private let searchConversationUseCase: SearchConversationUseCase @@ -100,7 +106,10 @@ final class ConversationListViewModel: ObservableObject { isSearching = false isSearchExecuted = false editedText = "" + searchQuery = "" searchResults.removeAll() + searchPage = 0 + hasMoreSearchResults = false } func searchText() async { @@ -110,10 +119,43 @@ final class ConversationListViewModel: ObservableObject { defer { isLoading = false } do { - searchResults = try await searchConversationUseCase.execute(query) + let page = try await searchConversationUseCase.execute( + query, + page: 0, + size: pageSize + ) + searchQuery = query + searchPage = page.page + hasMoreSearchResults = page.hasNextPage + searchResults = page.items isSearchExecuted = true } catch { alertMessage = error.localizedDescription } } + + func loadMoreIfNeeded(at index: Int) async { + guard isSearchExecuted, + hasMoreSearchResults, + !isLoading, + index >= searchResults.count - loadMorePrefetchCount + else { return } + + isLoading = true + defer { isLoading = false } + + do { + let nextPage = searchPage + 1 + let page = try await searchConversationUseCase.execute( + searchQuery, + page: nextPage, + size: pageSize + ) + searchPage = page.page + hasMoreSearchResults = page.hasNextPage + searchResults.append(contentsOf: page.items) + } catch { + alertMessage = error.localizedDescription + } + } } From a5fc328d74add869952df68a5d3fc51a83381a39 Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Tue, 1 Sep 2026 18:14:27 +0900 Subject: [PATCH 10/11] =?UTF-8?q?[CHORE]=20NetworkManager=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Core/Network/NetworkManager.swift | 22 ++------ GAMSS/Sources/Data/Storage/TokenStorage.swift | 55 +++++++++++++------ .../Sources/Presentation/Root/RootView.swift | 4 +- 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/GAMSS/Sources/Core/Network/NetworkManager.swift b/GAMSS/Sources/Core/Network/NetworkManager.swift index 744b931..6e5640d 100644 --- a/GAMSS/Sources/Core/Network/NetworkManager.swift +++ b/GAMSS/Sources/Core/Network/NetworkManager.swift @@ -5,7 +5,6 @@ // Created by 이건준 on 7/19/26. // -import FirebaseAuth import Foundation protocol NetworkRequesting { @@ -70,23 +69,14 @@ final class NetworkManager: NetworkRequesting { if response.statusCode == 401, !isRetryAfterReissue { do { - try await TokenStorage.shared.reissueToken() + try await TokenStorage.shared.restoreSession() } catch { - Log.error("Token reissue failed, trying auto login: \(error)") - do { - try await DefaultAuthRepository( - networkManager: NetworkManager.shared, - tokenStorage: TokenStorage.shared - ).autoLogin() - } catch { - Log.error("Auto login after reissue failed: \(error)") - try? TokenStorage.shared.deleteTokens() - try? Auth.auth().signOut() - await MainActor.run { - LoginSession.shared.updateFromStorage() - } - throw NetworkError.expiredToken + Log.error("Session restore failed: \(error)") + TokenStorage.shared.clearSession() + await MainActor.run { + LoginSession.shared.updateFromStorage() } + throw NetworkError.expiredToken } return try await request(endpoint, responseType: responseType, isRetryAfterReissue: true) diff --git a/GAMSS/Sources/Data/Storage/TokenStorage.swift b/GAMSS/Sources/Data/Storage/TokenStorage.swift index eff9c7b..b14e1ab 100644 --- a/GAMSS/Sources/Data/Storage/TokenStorage.swift +++ b/GAMSS/Sources/Data/Storage/TokenStorage.swift @@ -5,37 +5,45 @@ // Created by 이건준 on 7/29/26. // +import FirebaseAuth import Foundation final class TokenStorage { static let shared = TokenStorage() - + + private lazy var authRepository: AuthRepository = DefaultAuthRepository( + networkManager: NetworkManager.shared, + tokenStorage: self + ) + private init() {} - - func reissueToken() async throws { - guard let refreshToken = readToken(.refreshToken) else { + + /// refresh 재발급을 시도하고, 실패하면 Firebase 세션으로 로그인 API를 다시 호출한다. + func restoreSession() async throws { + if let refreshToken = readToken(.refreshToken) { + do { + try await reissue(refreshToken: refreshToken) + return + } catch { + Log.error("Token reissue failed, trying auto login: \(error)") + } + } else { try deleteTokens() - throw NetworkError.expiredToken } - - let response = try await NetworkManager.shared.request( - AuthEndpoint.reissueToken(.init(refreshToken: refreshToken)), - responseType: APIResponse.self, - isRetryAfterReissue: true - ).data - try createTokens(accessToken: response.accessToken, refreshToken: response.refreshToken) + + try await authRepository.autoLogin() } - + func createTokens(accessToken: String, refreshToken: String) throws { try KeyChainManager.shared.create(account: .accessToken, data: accessToken) try KeyChainManager.shared.create(account: .refreshToken, data: refreshToken) Log.info("[Token updated]\naccessToken: \(accessToken)\nrefreshToken: \(refreshToken)", privacy: .privacy) } - + func setToken(_ token: String, for account: KeyChainAccount) throws { try KeyChainManager.shared.create(account: account, data: token) } - + func readToken(_ account: KeyChainAccount) -> String? { do { let token = try KeyChainManager.shared.read(account: account) @@ -45,11 +53,24 @@ final class TokenStorage { return nil } } - + func deleteTokens() throws { try KeyChainManager.shared.delete(account: .accessToken) try KeyChainManager.shared.delete(account: .refreshToken) Log.info("[Token Deleted]") } -} + func clearSession() { + try? deleteTokens() + try? Auth.auth().signOut() + } + + private func reissue(refreshToken: String) async throws { + let response = try await NetworkManager.shared.request( + AuthEndpoint.reissueToken(.init(refreshToken: refreshToken)), + responseType: APIResponse.self, + isRetryAfterReissue: true + ).data + try createTokens(accessToken: response.accessToken, refreshToken: response.refreshToken) + } +} diff --git a/GAMSS/Sources/Presentation/Root/RootView.swift b/GAMSS/Sources/Presentation/Root/RootView.swift index 1e28d78..9dadbe1 100644 --- a/GAMSS/Sources/Presentation/Root/RootView.swift +++ b/GAMSS/Sources/Presentation/Root/RootView.swift @@ -5,7 +5,6 @@ // Created by 이건준 on 8/12/26. // -import FirebaseAuth import SwiftUI struct RootView: View { @@ -68,8 +67,7 @@ struct RootView: View { loginSession.value = .loggedIn } catch { Log.error("Auto login failed: \(error)") - try? TokenStorage.shared.deleteTokens() - try? Auth.auth().signOut() + TokenStorage.shared.clearSession() loginSession.value = .notLoggedIn } } From e505f34dcd584d1c6dfa09afae8e4bbd7d250e45 Mon Sep 17 00:00:00 2001 From: dlrjswns Date: Tue, 1 Sep 2026 22:00:47 +0900 Subject: [PATCH 11/11] =?UTF-8?q?[FEAT]=20=EB=8C=80=ED=99=94=EC=B0=BD=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=8B=9C=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8F=B0=ED=8A=B8=20=EB=B0=8F=20=EC=83=89=EC=83=81=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConversationList/Components/ConversationSearchView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GAMSS/Sources/Presentation/ConversationList/Components/ConversationSearchView.swift b/GAMSS/Sources/Presentation/ConversationList/Components/ConversationSearchView.swift index 0cc568a..e4031cb 100644 --- a/GAMSS/Sources/Presentation/ConversationList/Components/ConversationSearchView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/Components/ConversationSearchView.swift @@ -19,6 +19,8 @@ struct ConversationSearchView: View { .typography(.body4Medium) .foregroundStyle(Color.colorGray400) } + .typography(.body4Medium) + .foregroundStyle(Color.colorGray950) .padding(.vertical, 11) .padding(.horizontal, 16) .background(Color.colorGray075)