Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions GAMSS/Sources/Core/Components/Navigation/NavigationBarHider.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
181 changes: 181 additions & 0 deletions GAMSS/Sources/Core/Components/Navigation/NavigationBarView.swift
Original file line number Diff line number Diff line change
@@ -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<Trailing: View>: 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")
}
}
}
22 changes: 22 additions & 0 deletions GAMSS/Sources/Core/LoginSession.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 1 addition & 1 deletion GAMSS/Sources/Core/LoginState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation
enum LoginState {
/// 로그인 안되어 있음
case notLoggedIn
/// 자동 로그인 설정이 되어있음, accessToken 갱신 필요
/// 자동 로그인 대기
case autoLoginPending
/// 로그인 되어있음
case loggedIn
Expand Down
31 changes: 17 additions & 14 deletions GAMSS/Sources/Core/Network/NetworkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,20 @@ import Foundation
protocol NetworkRequesting {
func request<T: Decodable & Sendable>(
_ endpoint: Endpoint,
responseType: T.Type
responseType: T.Type,
isRetryAfterReissue: Bool
) async throws -> T
}

extension NetworkRequesting {
func request<T: Decodable & Sendable>(
_ endpoint: Endpoint,
responseType: T.Type
) async throws -> T {
try await request(endpoint, responseType: responseType, isRetryAfterReissue: false)
}
}

final class NetworkManager: NetworkRequesting {
static let shared = NetworkManager()

Expand All @@ -28,15 +38,6 @@ final class NetworkManager: NetworkRequesting {
self.decoder = decoder
}

func request<T: Decodable & Sendable>(
_ endpoint: Endpoint,
responseType: T.Type
) async throws -> T {
try await request(endpoint, responseType: responseType, isRetryAfterReissue: false)
}

/// `isRetryAfterReissue`가 true면 이미 한 번 토큰을 재발급받고 재시도하는 중이라는 뜻 —
/// 여기서 또 EXPIRED_TOKEN이 나도 다시 재발급을 시도하지 않는다(무한 루프 방지).
func request<T: Decodable & Sendable>(
_ endpoint: Endpoint,
responseType: T.Type,
Expand Down Expand Up @@ -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)

@cchanmi cchanmi Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p1

reissue 실패 → autoLogin 폴백 로직이 핵심 수정사항인 것 같은데 관련 테스트 코드가 있으면 좋을 것 같아요

성공/실패(missingFirebaseUser, firebaseSignInFailed) 케이스만이라도 커버되면 좋을 것 같고... 아니면 따로 검증할 방법 있을까요?

}

Expand Down
Loading
Loading