diff --git a/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift b/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift new file mode 100644 index 0000000..2f8bdf0 --- /dev/null +++ b/GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift @@ -0,0 +1,99 @@ +// +// NavigationBarHider.swift +// GAMSS +// +// Created by 이건준 on 8/24/26. +// + +import SwiftUI + +/// NavigationStack 안의 시스템 네비게이션 바를 항상 숨긴다. +/// 탭 바는 SwiftUI `.toolbar(.hidden, for: .tabBar)`로 처리해야 레이아웃이 전체 높이로 확장된다. +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.hideNavigationBar(on: navigationController) + } + } + + func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + hideNavigationBar(on: navigationController) + originalDelegate?.navigationController?( + navigationController, + willShow: viewController, + animated: animated + ) + } + + func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + hideNavigationBar(on: navigationController) + originalDelegate?.navigationController?( + navigationController, + didShow: viewController, + animated: animated + ) + } + + private func hideNavigationBar(on navigationController: UINavigationController) { + navigationController.setNavigationBarHidden(true, animated: false) + // 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/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/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..b272fc6 100644 --- a/GAMSS/Sources/Core/LoginState.swift +++ b/GAMSS/Sources/Core/LoginState.swift @@ -10,7 +10,7 @@ import Foundation enum LoginState { /// 로그인 안되어 있음 case notLoggedIn - /// 자동 로그인 설정이 되어있음, accessToken 갱신 필요 + /// 자동 로그인 대기 case autoLoginPending /// 로그인 되어있음 case loggedIn diff --git a/GAMSS/Sources/Core/Network/NetworkManager.swift b/GAMSS/Sources/Core/Network/NetworkManager.swift index 5e9951c..6e5640d 100644 --- a/GAMSS/Sources/Core/Network/NetworkManager.swift +++ b/GAMSS/Sources/Core/Network/NetworkManager.swift @@ -10,10 +10,20 @@ 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,15 +38,6 @@ 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면 이미 한 번 토큰을 재발급받고 재시도하는 중이라는 뜻 — - /// 여기서 또 EXPIRED_TOKEN이 나도 다시 재발급을 시도하지 않는다(무한 루프 방지). func request( _ endpoint: Endpoint, responseType: T.Type, @@ -68,14 +69,16 @@ 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: \(error)") + Log.error("Session restore failed: \(error)") + TokenStorage.shared.clearSession() + await MainActor.run { + LoginSession.shared.updateFromStorage() + } throw NetworkError.expiredToken } - // 재발급된 토큰은 HttpHeader가 요청을 다시 만들 때 Keychain에서 새로 읽어오므로, - // 원래 요청을 그대로 한 번 더 시도하면 된다. return try await request(endpoint, responseType: responseType, isRetryAfterReissue: true) } 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/DefaultAuthRepository.swift b/GAMSS/Sources/Data/Repository/DefaultAuthRepository.swift index 3468d8d..8a4035b 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( @@ -91,7 +107,8 @@ final class DefaultAuthRepository: AuthRepository { ) async throws { 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/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/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..b14e1ab 100644 --- a/GAMSS/Sources/Data/Storage/TokenStorage.swift +++ b/GAMSS/Sources/Data/Storage/TokenStorage.swift @@ -5,33 +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 { - try TokenStorage.shared.deleteTokens() - return + + /// 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() } - - 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) @@ -41,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/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/Entity/AuthError.swift b/GAMSS/Sources/Domain/Entity/AuthError.swift index b188697..7deac75 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..7e1c383 100644 --- a/GAMSS/Sources/Domain/Repository/AuthRepository.swift +++ b/GAMSS/Sources/Domain/Repository/AuthRepository.swift @@ -13,6 +13,8 @@ protocol AuthRepository { credential: ASAuthorizationAppleIDCredential, nonce: String ) async throws + + 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/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/Archive/ArchiveView.swift b/GAMSS/Sources/Presentation/Archive/ArchiveView.swift index e97ebe5..d1fc93f 100644 --- a/GAMSS/Sources/Presentation/Archive/ArchiveView.swift +++ b/GAMSS/Sources/Presentation/Archive/ArchiveView.swift @@ -16,54 +16,40 @@ 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) { + 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) { + 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() } } } 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 0c5e03a..292272b 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 @@ -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 { @@ -51,7 +57,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) } @@ -62,7 +70,6 @@ struct ArchiveDetailView: View { } } } - .toolbar(.hidden, for: .navigationBar) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.colorWhite) .overlay { @@ -92,7 +99,26 @@ struct ArchiveDetailView: View { } } } - .fullScreenCover(item: $selectedNote) { note in + .navigationDestination(item: $shredMode) { mode in + CardShredView( + viewModel: makeShredViewModel(for: mode), + stripImageName: "noteStrip", + onBack: { + if case .single(let cardId) = mode { + selectedNote = viewModel.notes.first { $0.id == cardId } + } + shredMode = nil + }, + onComplete: { + shredMode = nil + selectedNote = nil + scene.clear() + Task { await viewModel.load() } + } + ) + } + + if let note = selectedNote { CardDetailView( viewModel: CardDetailViewModel( cardId: note.id, @@ -101,58 +127,42 @@ struct ArchiveDetailView: View { ) ), onClose: { - selectedNote = nil + withAnimation(.easeOut(duration: 0.12)) { + selectedNote = nil + } Task { await viewModel.load() } - } - ) - .presentationBackground(.clear) - } - .fullScreenCover(isPresented: $isShredPresented) { - CardShredView( - viewModel: CardShredViewModel( - deleteAllCardUseCase: DefaultDeleteAllCardUseCase( - cardRepository: DefaultCardRepository(networkManager: NetworkManager.shared) - ) - ), - stripImageName: "noteStrip", - onBack: { - isShredPresented = false }, - onComplete: { - isShredPresented = false - viewModel.clearNotes() - scene.clear() - 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) + .transition(.opacity) + .zIndex(1) } } + .animation(.easeOut(duration: 0.12), value: selectedNote?.id) + .hidesTabBar() } - - 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 { - isShredPresented = true - } label: { - Text("비우기") - .typography(.body5Medium) - .foregroundStyle(Color.colorGray900) - } - .typography(.body5Medium) - .foregroundStyle(Color.colorGray900) + + 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) + ) } } 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 diff --git a/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift b/GAMSS/Sources/Presentation/CardDetail/CardDetailView.swift index e39e3f3..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( @@ -60,6 +65,7 @@ struct CardDetailView: View { } } .task { + guard viewModel.card == nil else { return } await viewModel.loadCard() } .alert( @@ -83,32 +89,12 @@ struct CardDetailView: View { 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() - } - ) - } - } } private var bottomActions: some View { HStack(spacing: Spacing.spacing050) { OutlineButton(title: "기록 버리기") { - isShredPresented = true + onDiscard() } .frame(width: 97) @@ -151,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..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 @@ -56,7 +54,7 @@ struct CardShredView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.colorWhite) - .toolbar(.hidden, for: .navigationBar) + .hidesTabBar() .alert( viewModel.alertMessage ?? "", isPresented: Binding( @@ -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/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 b1e3e3b..8e06446 100644 --- a/GAMSS/Sources/Presentation/Chat/ChatView.swift +++ b/GAMSS/Sources/Presentation/Chat/ChatView.swift @@ -91,7 +91,27 @@ 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) + .hidesTabBar() .onChange(of: viewModel.riskDetection) { _, newValue in if newValue != nil { isInputFocused = false } } @@ -209,7 +229,6 @@ struct ChatView: View { } } } - .toolbar(.hidden, for: .navigationBar) .task { await viewModel.start() } @@ -231,59 +250,32 @@ 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개(종료, 토큰 사용량). - /// 시스템 네비게이션 바는 `.toolbar(.hidden, for: .navigationBar)`로 숨기고 이 헤더가 대신한다. 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/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) diff --git a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift index 1e4c474..681a454 100644 --- a/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift +++ b/GAMSS/Sources/Presentation/ConversationList/ConversationListView.swift @@ -22,79 +22,68 @@ 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 - }) - .frame(height: 64) - - if viewModel.isSearching { - ConversationSearchView(onTappedCancelButton: { + }, + 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) - .transition( - .move(edge: .top) - .combined(with: .opacity) - ) - } - - dateHeader.padding(.vertical, Spacing.spacing150) - - if !viewModel.isLoading && viewModel.displayedConversations.isEmpty { - ConversationListEmptyView() - } else { + }, + 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(viewModel.displayedConversations) { conversation in - switch viewModel.currentMode { - case .normal: - Button { - selectedConversation = conversation - } label: { - ConversationRowView( - currentMode: viewModel.currentMode, - conversation: conversation, - isSelected: false - ) + ForEach(Array(viewModel.displayedConversations.enumerated()), id: \.element.id) { index, conversation in + conversationRow(for: conversation) + .onAppear { + Task { + await viewModel.loadMoreIfNeeded(at: index) + } } - .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) - } + } + + if viewModel.isLoading && !viewModel.displayedConversations.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, Spacing.spacing200) } } } - Spacer() + .frame(maxWidth: .infinity, maxHeight: .infinity) + if viewModel.currentMode == .delete { Button { - Task { - await viewModel.deleteConversations() - } + Task { await viewModel.deleteConversations() } } label: { Text("삭제하기") .typography(.body3Medium) @@ -110,41 +99,41 @@ struct ConversationListView: View { ? Color.colorRed : Color.colorGray075 ) - .clipShape( - RoundedRectangle(cornerRadius: 12) - ) + .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) - .padding(.horizontal, Spacing.spacing400) - .toolbar(.hidden, for: .navigationBar) - .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 ) - .toolbar(.hidden, for: .tabBar) - } - .navigationDestination(isPresented: $isSettingPresented) { - SettingView().toolbar(.hidden, for: .tabBar) - } - .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 } } @@ -152,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: { @@ -172,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 + } + } } diff --git a/GAMSS/Sources/Presentation/Home/HomeView.swift b/GAMSS/Sources/Presentation/Home/HomeView.swift index ee23a3e..b0faaf4 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 { @@ -48,35 +42,48 @@ 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)로 위치를 잡고 있어서 그 영역이 // 줄어들면 같이 움직여 보인다 — 키보드에 반응해 레이아웃이 줄어들지 않게 한다. .ignoresSafeArea(.keyboard, edges: .bottom) - .navigationBarHidden(true) .onChange(of: viewModel.pendingFirstMessage) { _, newValue in if newValue != nil { isInputFocused = false } } @@ -94,10 +101,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 } @@ -119,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/MainTabView.swift b/GAMSS/Sources/Presentation/Root/MainTabView.swift index 511efc6..aacdfc6 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/Root/RootView.swift b/GAMSS/Sources/Presentation/Root/RootView.swift index 46cf639..9dadbe1 100644 --- a/GAMSS/Sources/Presentation/Root/RootView.swift +++ b/GAMSS/Sources/Presentation/Root/RootView.swift @@ -7,14 +7,16 @@ 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 +24,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 +60,17 @@ struct RootView: View { .environment(loginSession) .environment(UserManager.shared) } + + private func performAutoLogin() async { + do { + try await loginUseCase.autoLogin() + loginSession.value = .loggedIn + } catch { + Log.error("Auto login failed: \(error)") + TokenStorage.shared.clearSession() + loginSession.value = .notLoggedIn + } + } } #Preview { diff --git a/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift b/GAMSS/Sources/Presentation/Setting/Account/AccountView.swift index 105e6a7..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) @@ -38,7 +36,6 @@ struct AccountView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color.colorWhite) - .toolbar(.hidden, for: .navigationBar) .alert( viewModel.errorMessage ?? "", isPresented: Binding( @@ -88,21 +85,7 @@ struct AccountView: View { } } } - } - - 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() - } + .hidesTabBar() } @ViewBuilder @@ -117,7 +100,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 } @@ -172,7 +154,7 @@ struct AccountView: View { ) ) ) - .environment(LoginSession()) + .environment(LoginSession.shared) .environment(UserManager.shared) } } diff --git a/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift b/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift index 27b03ff..b33f8b4 100644 --- a/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift +++ b/GAMSS/Sources/Presentation/Setting/NicknameEdit/NicknameEditView.swift @@ -16,74 +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) - } -} - -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) - } + .hidesTabBar() } } diff --git a/GAMSS/Sources/Presentation/Setting/SettingView.swift b/GAMSS/Sources/Presentation/Setting/SettingView.swift index ee33eb0..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 @@ -32,28 +30,13 @@ struct SettingView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.colorWhite) - .toolbar(.hidden, for: .navigationBar) + .hidesTabBar() .sheet(item: $presentedWebPage) { page in SafariView(url: page.url) .ignoresSafeArea() } } - 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) 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") }