diff --git a/Projects/App/Sources/AppFeature.swift b/Projects/App/Sources/AppFeature.swift index 38287b6..24c95cc 100644 --- a/Projects/App/Sources/AppFeature.swift +++ b/Projects/App/Sources/AppFeature.swift @@ -50,6 +50,10 @@ struct AppFeature { case .intro(.delegate(.loginSucceeded)): state.route = .mainTab return .none + + case .mainTab(.delegate(.needsAuthentication)): + state.route = .intro + return .none case .splash, .intro, .mainTab: return .none diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index ffd1ab3..adcf400 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -10,6 +10,7 @@ import ComposableArchitecture import DoriDesignSystem import DoriNetwork import DoriNetworkImpl +import FeatureMyPage import FeatureOnboarding import PlatformKakaoAuth import PlatformKeychain @@ -42,6 +43,10 @@ struct DoriApp: App { networkService: networkService, tokenStore: tokenStore ) + $0.myPageAPIClient = .live( + networkService: networkService, + tokenStore: tokenStore + ) } FontManager.registerAllFonts() diff --git a/Projects/App/Sources/MainTabView.swift b/Projects/App/Sources/MainTabView.swift index 7169c17..7080325 100644 --- a/Projects/App/Sources/MainTabView.swift +++ b/Projects/App/Sources/MainTabView.swift @@ -30,6 +30,11 @@ struct MainTabFeature { case calendar(CalendarFeature.Action) case history(HistoryFeature.Action) case myPage(MyPageFeature.Action) + case delegate(Delegate) + + enum Delegate: Equatable { + case needsAuthentication + } } var body: some ReducerOf { @@ -48,7 +53,16 @@ struct MainTabFeature { state.selectedTab = tab return .none - case .calendar, .history, .myPage: + case .myPage(.delegate(.didLogout)): + return .send(.delegate(.needsAuthentication)) + + case .myPage(.delegate(.didWithdraw)): + return .send(.delegate(.needsAuthentication)) + + case .myPage(.delegate(.authExpired)): + return .send(.delegate(.needsAuthentication)) + + case .calendar, .history, .myPage, .delegate: return .none } } diff --git a/Projects/Core/DoriDesignSystem/Sources/AlertButton.swift b/Projects/Core/DoriDesignSystem/Sources/AlertButton.swift new file mode 100644 index 0000000..2347a38 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/AlertButton.swift @@ -0,0 +1,41 @@ +// +// AlertButton.swift +// DoriDesignSystem +// +// Created by 강동영 on 2/13/26. +// + +public struct AlertButton { + public let title: String + public let action: @MainActor () -> Void + + public init( + title: String, + action: @escaping @MainActor () -> Void + ) { + self.title = title + self.action = action + } + + public init( + _ type: AlertButtonType, + action: @escaping @MainActor () -> Void + ) { + self.title = type.title + self.action = action + } + + public enum AlertButtonType { + case yes + case no + + public var title: String { + switch self { + case .yes: + return "예" + case .no: + return "아니오" + } + } + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriCommonAlert.swift b/Projects/Core/DoriDesignSystem/Sources/DoriCommonAlert.swift new file mode 100644 index 0000000..a646390 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/DoriCommonAlert.swift @@ -0,0 +1,112 @@ +// +// DoriCommonAlert.swift +// DoriDesignSystem +// +// Created by 강동영 on 2/13/26. +// + +import SwiftUI + +public struct DoriCommonAlert: View { + @Binding var isPresented: Bool + + private let title: String + private let description: String? + private let secondaryButton: AlertButton? + private let primaryButton: AlertButton + + public init( + isPresented: Binding, + title: String, + description: String? = nil, + secondaryButton: AlertButton?, + primaryButton: AlertButton + ) { + self._isPresented = isPresented + self.title = title + self.description = description + self.secondaryButton = secondaryButton + self.primaryButton = primaryButton + } + + public var body: some View { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { + isPresented = false + secondaryButton?.action() + } + + VStack(spacing: 0) { + contentArea + buttonArea + } + .padding(16) + .background(.doriWhite) + .cornerRadius(10) + .padding(.horizontal, 24) + .scaleEffect(isPresented ? 1.0 : 0.8) + .opacity(isPresented ? 1.0 : 0.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isPresented) + } + } + + var contentArea: some View { + VStack(alignment: .center, spacing: 8) { + Text(title) + .pretendard(.headline(.h1)) + .foregroundStyle(.doriBlack) + + if let description = description { + Text(description) + .pretendard(.body(.r4)) + .foregroundStyle(.grey600) + .multilineTextAlignment(.center) + } + } + .padding(.vertical, 40) + } + + var buttonArea: some View { + HStack(spacing: 10) { + if let secondaryButton = secondaryButton { + PrimaryButton(title: secondaryButton.title) { + secondaryButton.action() + } + .backgroundColor(.grey100) + .foregroundColor(.black) + } + + PrimaryButton(title: primaryButton.title) { + primaryButton.action() + } + } + .frame(maxHeight: 56) + } +} + +#Preview { + DoriCommonAlert( + isPresented: .constant(true), + title: "로그아웃 하시겠습니까?", + secondaryButton: AlertButton(.no) { + print("no") + }, + primaryButton: AlertButton(.yes) { + print("yes") + } + ) +} + +#Preview { + DoriCommonAlert( + isPresented: .constant(true), + title: "회원탈퇴", + description: "정말 도리를 탈퇴하실건가요?\n재가입 시에도 이용 내역은 복구되지 않습니다.", + secondaryButton: AlertButton(.no) { + }, + primaryButton: AlertButton(.yes) { + } + ) +} diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriToast.swift b/Projects/Core/DoriDesignSystem/Sources/DoriToast.swift new file mode 100644 index 0000000..e6d2c5e --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/DoriToast.swift @@ -0,0 +1,42 @@ +// +// DoriToast.swift +// DoriDesignSystem +// +// Created by 강동영 on 2/13/26. +// + +import Foundation + +public struct DoriToast: Equatable, Sendable { + public let id: UUID + public let type: ToastType + public let message: String + public let duration: TimeInterval + + public init( + id: UUID = UUID(), + type: ToastType, + message: String, + duration: TimeInterval? = nil + ) { + self.id = id + self.type = type + self.message = message + self.duration = duration ?? type.defaultDuration + } +} + +public enum ToastType: Equatable, Sendable { + case success + case error + case info + + public var defaultDuration: TimeInterval { + switch self { + case .success, .info: + return 2.0 + case .error: + return 3.0 + } + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriToastModifier.swift b/Projects/Core/DoriDesignSystem/Sources/DoriToastModifier.swift new file mode 100644 index 0000000..d40c047 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/DoriToastModifier.swift @@ -0,0 +1,62 @@ +// +// DoriToastModifier.swift +// DoriDesignSystem +// +// Created by 강동영 on 2/13/26. +// + +import SwiftUI + +struct DoriToastModifier: ViewModifier { + let toast: DoriToast? + let alignment: Alignment + let onDismiss: @MainActor () -> Void + + func body(content: Content) -> some View { + content + .overlay(alignment: alignment) { + Group { + if let toast { + DoriToastView(toast: toast) + .id(toast.id) + .transition( + .move(edge: alignment == .top ? .top : .bottom) + .combined(with: .opacity) + ) + .padding( + alignment == .top ? .top : .bottom, + 8 + ) + .task(id: toast.id) { + try? await Task.sleep(for: .seconds(toast.duration)) + guard !Task.isCancelled else { return } + onDismiss() + } + } + } + .animation( + .spring( + response: 0.35, + dampingFraction: 0.8 + ), + value: toast + ) + } + } +} + +public extension View { + func doriToast( + _ toast: DoriToast?, + alignment: Alignment = .bottom, + onDismiss: @escaping @MainActor () -> Void + ) -> some View { + modifier( + DoriToastModifier( + toast: toast, + alignment: alignment, + onDismiss: onDismiss + ) + ) + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriToastView.swift b/Projects/Core/DoriDesignSystem/Sources/DoriToastView.swift new file mode 100644 index 0000000..48583fa --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/DoriToastView.swift @@ -0,0 +1,127 @@ +// +// DoriToastView.swift +// DoriDesignSystem +// +// Created by 강동영 on 2/13/26. +// + +import SwiftUI + +@MainActor +public struct DoriToastView: View { + private let toast: DoriToast + + public init(toast: DoriToast) { + self.toast = toast + } + + public var body: some View { + HStack(spacing: 8) { + if toast.type != .info { + Image(systemName: toast.type.iconName) + .foregroundStyle(toast.type.iconColor) + } + + Text(toast.message) + .pretendard(.regular(.r15)) + .foregroundStyle(.doriWhite) + .lineLimit(2) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .center) + .background(toast.type.backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow( + color: .black.opacity(0.15), + radius: 8, + x: 0, + y: 4 + ) + .padding(.horizontal, 20) + .padding(.bottom, 50) + } + + public static func == ( + lhs: DoriToastView, + rhs: DoriToastView + ) -> Bool { + lhs.toast == rhs.toast + } +} + +extension ToastType { + var iconName: String { + switch self { + case .success: + return "checkmark.circle.fill" + case .error: + return "exclamationmark.circle.fill" + case .info: + return "" + } + } + + var iconColor: Color { + switch self { + case .success: + return .white + case .error: + return .white + case .info: + return .white + } + } + + var backgroundColor: Color { + switch self { + case .success: + return Color( + red: 0.2, + green: 0.7, + blue: 0.4 + ) + case .error: + return Color( + red: 0.9, + green: 0.3, + blue: 0.3 + ) + case .info: + return UIAsset.Colors.doriBlack.color.opacity(0.8) + } + } +} + +#Preview("Toast Types") { + @Previewable @State var toast: DoriToast? = nil + + VStack(spacing: 16) { + Button("Success") { + toast = DoriToast( + type: .success, + message: "로그아웃이 완료되었습니다." + ) + } + + Button("Error") { + toast = DoriToast( + type: .error, + message: "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요." + ) + } + + Button("Info") { + toast = DoriToast( + type: .info, + message: "새로운 거래가 등록되었습니다." + ) + } + } + .frame(maxHeight: .infinity) + .frame(maxWidth: .infinity) + .background(Color(.white)) + .doriToast(toast) { + toast = nil + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift b/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift new file mode 100644 index 0000000..2c3d969 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift @@ -0,0 +1,75 @@ +// +// PrimaryButton.swift +// DoriDesignSystem +// +// Created by 강동영 on 2/5/26. +// + +import SwiftUI + +public struct PrimaryButton: View { + private let titleKey: String + private let titleStyle: TypoSemantic + private let action: @MainActor () -> Void + + private var foregroundColor: Color = UIAsset.Colors.doriWhite.color + private var backgroundColor: Color = UIAsset.Colors.main.color + private var cornerRadius: CGFloat = 10 + + public var body: some View { + Button { + action() + } label: { + Text(titleKey) + .pretendard(titleStyle) + .foregroundStyle(foregroundColor) + .frame(maxWidth: .infinity, maxHeight: 53) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(backgroundColor) + ) + } + } + + public init( + title: String, + style: TypoSemantic = .body(.sb3), + action: @escaping @MainActor () -> Void = {} + ) { + self.titleKey = title + self.titleStyle = style + self.action = action + } +} + +public extension PrimaryButton { + func foregroundColor(_ color: Color) -> Self { + var button = self + button.foregroundColor = color + return button + } + + func foregroundColor(_ asset: UIAsset.Colors) -> Self { + var button = self + button.foregroundColor = asset.color + return button + } + + func backgroundColor(_ color: Color) -> Self { + var button = self + button.backgroundColor = color + return button + } + + func backgroundColor(_ asset: UIAsset.Colors) -> Self { + var button = self + button.backgroundColor = asset.color + return button + } + + func cornerRadius(_ value: CGFloat) -> Self { + var button = self + button.cornerRadius = value + return button + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/Typography/TypoToken.swift b/Projects/Core/DoriDesignSystem/Sources/Typography/TypoToken.swift index 3409516..03ea043 100644 --- a/Projects/Core/DoriDesignSystem/Sources/Typography/TypoToken.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Typography/TypoToken.swift @@ -35,6 +35,7 @@ public enum TypoStyle { public func getFontStyle(with provider: FontProvider) -> FontStyle { let spec = styleSpec let fontName = spec.weight.getFontName(from: provider) + return FontStyle(.custom(fontName), size: spec.size) } } diff --git a/Projects/Feature/MyPage/Sources/CommonWebView.swift b/Projects/Feature/MyPage/Sources/CommonWebView.swift new file mode 100644 index 0000000..ea25999 --- /dev/null +++ b/Projects/Feature/MyPage/Sources/CommonWebView.swift @@ -0,0 +1,298 @@ +// +// CommonWebView.swift +// Dori-iOS +// +// Created by 강동영 on 2/5/26. +// + +import SwiftUI +import WebKit + +// MARK: - WKWebViewRepresentable + +struct WKWebViewRepresentable: UIViewRepresentable { + let url: URL + @Binding var isLoading: Bool + @Binding var progress: Double + @Binding var canGoBack: Bool + @Binding var canGoForward: Bool + + var onNavigationAction: ((WKNavigationAction) -> WKNavigationActionPolicy)? + var onDidFinish: (() -> Void)? + var onDidFail: ((Error) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.allowsInlineMediaPlayback = true + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + webView.allowsBackForwardNavigationGestures = true + + // Progress 관찰 + webView.addObserver( + context.coordinator, + forKeyPath: #keyPath(WKWebView.estimatedProgress), + options: .new, + context: nil + ) + + // 초기 URL 로드 + let request = URLRequest(url: url) + webView.load(request) + + // Coordinator에 웹뷰 참조 저장 + context.coordinator.webView = webView + + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + // 현재 로딩 중인 URL과 새 URL 비교 + let currentURL = webView.url?.absoluteString ?? "" + let newURL = url.absoluteString + + // URL이 실제로 다를 때만 로드 + // (쿼리 파라미터 제외하고 비교하려면 추가 로직 필요) + if currentURL != newURL && !context.coordinator.isLoadingURL(newURL) { + let request = URLRequest(url: url) + webView.load(request) + context.coordinator.markURLAsLoading(newURL) + } + } + + static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) { + webView.removeObserver(coordinator, forKeyPath: #keyPath(WKWebView.estimatedProgress)) + } + + // MARK: - Coordinator + + class Coordinator: NSObject, WKNavigationDelegate { + var parent: WKWebViewRepresentable + weak var webView: WKWebView? + private var loadingURLs: Set = [] + + init(_ parent: WKWebViewRepresentable) { + self.parent = parent + } + + func isLoadingURL(_ urlString: String) -> Bool { + return loadingURLs.contains(urlString) + } + + func markURLAsLoading(_ urlString: String) { + loadingURLs.insert(urlString) + } + + // KVO - Progress 관찰 + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey : Any]?, + context: UnsafeMutableRawPointer? + ) { +// if keyPath == #keyPath(WKWebView.estimatedProgress), +// let webView = object as? WKWebView { +// DispatchQueue.main.async { +// self.parent.progress = webView.estimatedProgress +// } +// } + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if let handler = parent.onNavigationAction { + let policy = handler(navigationAction) + decisionHandler(policy) + } else { + decisionHandler(.allow) + } + + DispatchQueue.main.async { + self.parent.isLoading = true + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + DispatchQueue.main.async { + self.parent.isLoading = false + self.parent.canGoBack = webView.canGoBack + self.parent.canGoForward = webView.canGoForward + self.parent.onDidFinish?() + + // 로딩 완료 후 URL 목록에서 제거 + if let url = webView.url?.absoluteString { + self.loadingURLs.remove(url) + } + } + } + + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error + ) { + handleError(error, webView: webView) + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + handleError(error, webView: webView) + } + + private func handleError(_ error: Error, webView: WKWebView) { + let nsError = error as NSError + + // -999는 취소 에러이므로 무시 + guard nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled else { + DispatchQueue.main.async { + self.parent.isLoading = false + self.parent.onDidFail?(error) + } + return + } + + // 취소 에러는 조용히 처리 + DispatchQueue.main.async { + self.parent.isLoading = false + + // 로딩 목록에서 제거 + if let url = webView.url?.absoluteString { + self.loadingURLs.remove(url) + } + } + } + } +} + +// MARK: - CommonWebView + +struct CommonWebView: View { + let navigationTitle: String + let url: URL + + @State private var isLoading = false + @State private var progress: Double = 0 + @State private var canGoBack = false + @State private var canGoForward = false + @State private var webViewStore = WebViewStore() + + init( + navigationTitle: String, + url: URL + ) { + self.navigationTitle = navigationTitle + self.url = url + } + var body: some View { + VStack(spacing: 0) { + // 진행률 표시 + if isLoading && progress < 1.0 { + ProgressView(value: progress, total: 1.0) + .progressViewStyle(.linear) + .tint(.blue) + } + + // 웹뷰 + WKWebViewRepresentable( + url: url, + isLoading: $isLoading, + progress: $progress, + canGoBack: $canGoBack, + canGoForward: $canGoForward, + onNavigationAction: { action in + return .allow + }, + onDidFinish: { + print("✅ 로딩 완료: \(url.absoluteString)") + }, + onDidFail: { error in + // 실제 에러만 출력 (-999 제외) + print("❌ 로딩 실패: \(error.localizedDescription)") + } + ) + .onAppear { + // 웹뷰 참조 저장 (필요시) + } + + // 네비게이션 툴바 + toolbarView + } + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + } + + private var toolbarView: some View { + HStack(spacing: 20) { + Button { + webViewStore.webView?.goBack() + } label: { + Image(systemName: "chevron.left") + .foregroundColor(canGoBack ? .blue : .gray) + } + .disabled(!canGoBack) + + Button { + webViewStore.webView?.goForward() + } label: { + Image(systemName: "chevron.right") + .foregroundColor(canGoForward ? .blue : .gray) + } + .disabled(!canGoForward) + + Spacer() + + Button { + webViewStore.webView?.reload() + } label: { + Image(systemName: "arrow.clockwise") + } + + Button { + if isLoading { + webViewStore.webView?.stopLoading() + } + } label: { + Image(systemName: "xmark") + } + .disabled(!isLoading) + } + .padding() + .background(Color(.systemBackground)) + .shadow(color: .black.opacity(0.1), radius: 1, y: -1) + } +} + +// MARK: - WebViewStore + +@Observable +class WebViewStore { + weak var webView: WKWebView? +} + +// MARK: - Constants + +extension NSError { + static let NSURLErrorCancelled = -999 +} + +// MARK: - Preview + +#Preview { + NavigationStack { + CommonWebView( + navigationTitle: "개인정보처리방침", + url: URL(string: "https://www.apple.com")! + ) + } +} diff --git a/Projects/Feature/MyPage/Sources/MyPageFeature.swift b/Projects/Feature/MyPage/Sources/MyPageFeature.swift index 64ec6ad..8e6e45e 100644 --- a/Projects/Feature/MyPage/Sources/MyPageFeature.swift +++ b/Projects/Feature/MyPage/Sources/MyPageFeature.swift @@ -6,38 +6,366 @@ // import ComposableArchitecture -import SwiftUI +import DoriDesignSystem +import DoriNetwork +import Foundation @Reducer public struct MyPageFeature { + @Dependency(\.myPageAPIClient) var myPageAPIClient + @Dependency(\.continuousClock) var clock + public init() {} + private enum CancelID { + case toastDismiss + } + @ObservableState public struct State: Equatable, Sendable { - public init() {} + public var navigationPath: [Route] + public var isLoading: Bool + public var isLogoutAlertPresented: Bool + public var isWithdrawAlertPresented: Bool + public var toastItem: DoriToast? + + public init( + isLoading: Bool = false, + isLogoutAlertPresented: Bool = false, + isWithdrawAlertPresented: Bool = false, + toastItem: DoriToast? = nil + ) { + self.navigationPath = [] + self.isLoading = isLoading + self.isLogoutAlertPresented = isLogoutAlertPresented + self.isWithdrawAlertPresented = isWithdrawAlertPresented + self.toastItem = toastItem + } } public enum Action: Equatable, Sendable { case onAppear + case privacyPolicyTapped + case navigationPathChanged([Route]) + + case logoutButtonTapped + case withdrawButtonTapped + case logoutAlertDismissed + case withdrawAlertDismissed + + case logoutConfirmed + case withdrawConfirmed + + case logoutSucceeded + case logoutFailed(RequestError) + case withdrawSucceeded + case withdrawFailed(RequestError) + + case toastDismissed + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case didLogout + case didWithdraw + case authExpired + } + } + + public enum Route: Hashable, Sendable { + case privacyPolicy } public func reduce(into state: inout State, action: Action) -> Effect { switch action { case .onAppear: return .none + + case .privacyPolicyTapped: + state.navigationPath.append(.privacyPolicy) + return .none + + case .navigationPathChanged(let path): + state.navigationPath = path + return .none + + case .logoutButtonTapped: + state.isLogoutAlertPresented = true + return .none + + case .withdrawButtonTapped: + state.isWithdrawAlertPresented = true + return .none + + case .logoutAlertDismissed: + state.isLogoutAlertPresented = false + return .none + + case .withdrawAlertDismissed: + state.isWithdrawAlertPresented = false + return .none + + case .logoutConfirmed: + state.isLogoutAlertPresented = false + state.isLoading = true + state.toastItem = nil + + let client = myPageAPIClient + + return .run { send in + do { + try await client.logout() + await send(.logoutSucceeded) + } catch { + await send(.logoutFailed(RequestError.from(error: error))) + } + } + + case .withdrawConfirmed: + state.isWithdrawAlertPresented = false + state.isLoading = true + state.toastItem = nil + + let client = myPageAPIClient + + return .run { send in + do { + try await client.withdraw() + await send(.withdrawSucceeded) + } catch { + await send(.withdrawFailed(RequestError.from(error: error))) + } + } + + case .logoutSucceeded: + state.isLoading = false + return showToast( + &state, + type: .info, + message: "성공적으로 로그아웃 되었습니다.", + additionalEffect: .send(.delegate(.didLogout)) + ) + + case .logoutFailed(let error): + state.isLoading = false + + if error == .unauthorized { + return .send(.delegate(.authExpired)) + } + + return showToast( + &state, + type: .error, + message: error.message + ) + + case .withdrawSucceeded: + state.isLoading = false + return showToast( + &state, + type: .info, + message: "성공적으로 회원탈퇴 되었습니다.", + additionalEffect: .send(.delegate(.didWithdraw)) + ) + + case .withdrawFailed(let error): + state.isLoading = false + + if error == .unauthorized { + return .send(.delegate(.authExpired)) + } + + return showToast( + &state, + type: .error, + message: error.message + ) + + case .toastDismissed: + state.toastItem = nil + return .cancel(id: CancelID.toastDismiss) + + case .delegate: + return .none + } + } + + private func showToast( + _ state: inout State, + type: ToastType, + message: String, + additionalEffect: Effect? = nil + ) -> Effect { + let toast = DoriToast( + type: type, + message: message + ) + state.toastItem = toast + + let clock = self.clock + let timerEffect: Effect = .run { send in + try await clock.sleep(for: .seconds(toast.duration)) + await send(.toastDismissed) + } + .cancellable( + id: CancelID.toastDismiss, + cancelInFlight: true + ) + + if let additionalEffect { + return .merge(timerEffect, additionalEffect) } + + return timerEffect } } -public struct MyPageView: View { - let store: StoreOf +@DependencyClient +public struct MyPageAPIClient: Sendable { + public var logout: @Sendable () async throws -> Void + public var withdraw: @Sendable () async throws -> Void +} + +private enum MyPageAPIClientError: LocalizedError { + case unconfigured + case invalidResponse + case backendError(String) + case missingRefreshToken - public init(store: StoreOf) { - self.store = store + var errorDescription: String? { + switch self { + case .unconfigured: + return "MyPageAPIClient가 구성되지 않았습니다." + case .invalidResponse: + return "서버 응답이 올바르지 않습니다." + case .backendError(let message): + return message + case .missingRefreshToken: + return "리프레시 토큰이 없습니다." + } } +} + +extension MyPageAPIClient: DependencyKey { + public static let liveValue = Self( + logout: { throw MyPageAPIClientError.unconfigured }, + withdraw: { throw MyPageAPIClientError.unconfigured } + ) +} + +extension MyPageAPIClient: TestDependencyKey { + public static let previewValue = Self( + logout: {}, + withdraw: {} + ) + + public static let testValue = Self() +} + +public extension MyPageAPIClient { + static func live( + networkService: any NetworkService, + tokenStore: any AuthTokenStoring + ) -> Self { + Self( + logout: { + let tokens = tokenStore.load() + guard let refreshToken = tokens.refreshToken, !refreshToken.isEmpty else { + throw MyPageAPIClientError.missingRefreshToken + } + + let endpoint = LogoutEndpoint(refreshToken: refreshToken) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) - public var body: some View { - Text("마이페이지") - .onAppear { store.send(.onAppear) } + if let apiError = response.error { + throw MyPageAPIClientError.backendError( + apiError.message ?? "로그아웃에 실패했습니다." + ) + } + + guard response.success else { + throw MyPageAPIClientError.invalidResponse + } + + try tokenStore.clear() + }, + withdraw: { + let endpoint = WithdrawEndpoint() + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + + if let apiError = response.error { + throw MyPageAPIClientError.backendError( + apiError.message ?? "회원탈퇴에 실패했습니다." + ) + } + + guard response.success else { + throw MyPageAPIClientError.invalidResponse + } + + try tokenStore.clear() + } + ) + } +} + +public extension DependencyValues { + var myPageAPIClient: MyPageAPIClient { + get { self[MyPageAPIClient.self] } + set { self[MyPageAPIClient.self] = newValue } + } +} + +public enum RequestError: Error, Equatable, Sendable { + case unauthorized + case invalidResponse + case decoding(String) + case http(Int) + case api(String) + case unknown(String) + + static func from(error: Error) -> Self { + if let requestError = error as? RequestError { + return requestError + } + + if let networkError = error as? NetworkError { + switch networkError { + case .unauthorized: + return .unauthorized + case .invalidResponse: + return .invalidResponse + case .http(let statusCode, _): + return .http(statusCode) + case .decoding(let error): + return .decoding(error.localizedDescription) + default: + return .unknown(networkError.localizedDescription) + } + } + + return .unknown(error.localizedDescription) + } + + var message: String { + switch self { + case .unauthorized: + return "인증이 만료되었습니다. 다시 로그인해주세요." + case .invalidResponse: + return "응답 형식이 올바르지 않습니다." + case .decoding(let message): + return "디코딩 에러: \(message)" + case .http(let statusCode): + return "서버 오류(\(statusCode))가 발생했습니다." + case .api(let message): + return message + case .unknown(let message): + return message + } } } diff --git a/Projects/Feature/MyPage/Sources/MyPageView.swift b/Projects/Feature/MyPage/Sources/MyPageView.swift new file mode 100644 index 0000000..9982726 --- /dev/null +++ b/Projects/Feature/MyPage/Sources/MyPageView.swift @@ -0,0 +1,194 @@ +// +// MyPageView.swift +// Dori-iOS +// +// Created by 강동영 on 2/5/26. +// + +import ComposableArchitecture +import DoriDesignSystem +import SwiftUI + +public struct MyPageView: View { + @Bindable var store: StoreOf + + private static let versionString = + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + private static let privacyPolicyURL = URL( + string: "https://xonmin.notion.site/dori-privacy-policy?source=copy_link" + )! + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + NavigationStack(path: navigationPathBinding) { + VStack(alignment: .leading, spacing: 24) { + settingInfoView + accountInfoView + + Spacer() + } + .padding(.horizontal, 16) + .background(.doriWhite) + .navigationTitle("마이페이지") + .toolbarTitleDisplayMode(.inline) + .overlay { + if store.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.2)) + } + } + .overlay { + DoriCommonAlert( + isPresented: logoutAlertBinding, + title: "로그아웃 하시겠습니까?", + secondaryButton: AlertButton(.no) { + store.send(.logoutAlertDismissed) + }, + primaryButton: AlertButton(.yes) { + store.send(.logoutConfirmed) + } + ) + .opacity(store.isLogoutAlertPresented ? 1 : 0) + } + .overlay { + DoriCommonAlert( + isPresented: withdrawAlertBinding, + title: "회원탈퇴", + description: "정말 도리를 탈퇴하실건가요?\n재가입 시에도 이용 내역은 복구되지 않습니다.", + secondaryButton: AlertButton(.no) { + store.send(.withdrawAlertDismissed) + }, + primaryButton: AlertButton(.yes) { + store.send(.withdrawConfirmed) + } + ) + .opacity(store.isWithdrawAlertPresented ? 1 : 0) + } + .onAppear { + store.send(.onAppear) + } + .navigationDestination(for: MyPageFeature.Route.self) { route in + switch route { + case .privacyPolicy: + CommonWebView( + navigationTitle: "개인정보처리방침", + url: Self.privacyPolicyURL + ) + } + } + } + .doriToast(store.toastItem, alignment: .bottom) { + store.send(.toastDismissed) + } + } + + private var settingInfoView: some View { + VStack(alignment: .leading, spacing: 16) { + Text("정보") + .pretendard(.subtitle(.m2)) + .foregroundStyle(.grey600) + + HStack { + Text("앱 버전") + .pretendard(.body(.r3)) + .foregroundStyle(.doriBlack) + Spacer() + Text(Self.versionString) + .pretendard(.body(.r3)) + .foregroundStyle(.grey400) + } + .padding(.bottom, 12) + + NavigationRow("개인정보처리방침") { + store.send(.privacyPolicyTapped) + } + .padding(.bottom, 29) + + + Divider() + } + } + + private var accountInfoView: some View { + VStack(alignment: .leading, spacing: 16) { + Text("계정") + .pretendard(.subtitle(.m2)) + .foregroundStyle(.grey600) + + NavigationRow("로그아웃") { + store.send(.logoutButtonTapped) + } + .padding(.bottom, 12) + + NavigationRow("탈퇴하기") { + store.send(.withdrawButtonTapped) + } + } + } + private var logoutAlertBinding: Binding { + Binding( + get: { store.isLogoutAlertPresented }, + set: { isPresented in + if !isPresented { + store.send(.logoutAlertDismissed) + } + } + ) + } + + private var withdrawAlertBinding: Binding { + Binding( + get: { store.isWithdrawAlertPresented }, + set: { isPresented in + if !isPresented { + store.send(.withdrawAlertDismissed) + } + } + ) + } + + private var navigationPathBinding: Binding<[MyPageFeature.Route]> { + Binding( + get: { store.navigationPath }, + set: { store.send(.navigationPathChanged($0)) } + ) + } +} + +#Preview { + MyPageView( + store: Store(initialState: MyPageFeature.State()) { + MyPageFeature() + } + ) +} + +struct NavigationRow: View { + private let title: String + private let action: () -> Void + init( + _ title: String, + action: @escaping @MainActor () -> Void + ) { + self.title = title + self.action = action + } + + var body: some View { + Button { + action() + } label: { + HStack { + Text(title) + .pretendard(.body(.r3)) + Spacer() + Image(systemName: "chevron.right") + } + .foregroundStyle(.doriBlack) + } + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Auth/AuthEndpoints.swift b/Projects/Infra/DoriNetwork/Sources/Auth/AuthEndpoints.swift index 66ddb99..d669a70 100644 --- a/Projects/Infra/DoriNetwork/Sources/Auth/AuthEndpoints.swift +++ b/Projects/Infra/DoriNetwork/Sources/Auth/AuthEndpoints.swift @@ -15,8 +15,50 @@ public struct KakaoLoginEndpoint: Endpoint { public let queryParameters: [String: String] = [:] public let body: Data? - public init(accessToken: String, baseURL: String = NetworkConfig.baseURL) { + public init( + accessToken: String, + baseURL: String = NetworkConfig.baseURL + ) { self.baseURL = baseURL self.body = try? JSONEncoder().encode(KakaoLoginRequest(accessToken: accessToken)) } } + +// MARK: - Auth Endpoints (logout / withdraw / refresh) + +public struct LogoutEndpoint: Endpoint { + public let baseURL: String = NetworkConfig.baseURL + public let path: String = "/auth/logout" + public let method: HTTPMethod = .POST + public let headers: [String: String] = [:] + public let queryParameters: [String: String] = [:] + public let body: Data? + + public init(refreshToken: String) { + self.body = try? JSONEncoder().encode(RefreshTokenRequest(refreshToken: refreshToken)) + } +} + +public struct WithdrawEndpoint: Endpoint { + public let baseURL: String = NetworkConfig.baseURL + public let path: String = "/auth/withdraw" + public let method: HTTPMethod = .POST + public let headers: [String: String] = [:] + public let queryParameters: [String: String] = [:] + public let body: Data? = nil + + public init() {} +} + +public struct RefreshEndpoint: Endpoint { + public let baseURL: String = NetworkConfig.baseURL + public let path: String = "/auth/refresh" + public let method: HTTPMethod = .POST + public let headers: [String: String] = [:] + public let queryParameters: [String: String] = [:] + public let body: Data? + + public init(refreshToken: String) { + self.body = try? JSONEncoder().encode(RefreshTokenRequest(refreshToken: refreshToken)) + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Auth/AuthRequests.swift b/Projects/Infra/DoriNetwork/Sources/Auth/AuthRequests.swift index c36b6d0..1f8c919 100644 --- a/Projects/Infra/DoriNetwork/Sources/Auth/AuthRequests.swift +++ b/Projects/Infra/DoriNetwork/Sources/Auth/AuthRequests.swift @@ -14,3 +14,11 @@ public struct KakaoLoginRequest: Codable, Equatable, Sendable { self.accessToken = accessToken } } + +public struct RefreshTokenRequest: Encodable, Sendable { + public let refreshToken: String + + public init(refreshToken: String) { + self.refreshToken = refreshToken + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/AuthResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/AuthResponses.swift index 3548975..65e2299 100644 --- a/Projects/Infra/DoriNetwork/Sources/Responses/AuthResponses.swift +++ b/Projects/Infra/DoriNetwork/Sources/Responses/AuthResponses.swift @@ -10,17 +10,33 @@ import Foundation // MARK: - Auth Response DTOs public struct SocialLoginResponse: Codable, Equatable, Sendable { - public let accessToken: String - public let refreshToken: String - public let id: Int64 + public let accessToken: String + public let refreshToken: String + public let id: Int64 - public init( - accessToken: String, - refreshToken: String, - id: Int64 - ) { - self.accessToken = accessToken - self.refreshToken = refreshToken - self.id = id - } + public init( + accessToken: String, + refreshToken: String, + id: Int64 + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.id = id + } +} + +public struct TokenRefreshResponse: Decodable, Equatable, Sendable { + public let accessToken: String + public let refreshToken: String + public let id: Int + + public init( + accessToken: String, + refreshToken: String, + id: Int + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.id = id + } } diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/SuccessResponse.swift b/Projects/Infra/DoriNetwork/Sources/Responses/SuccessResponse.swift index fef2ebf..7e37452 100644 --- a/Projects/Infra/DoriNetwork/Sources/Responses/SuccessResponse.swift +++ b/Projects/Infra/DoriNetwork/Sources/Responses/SuccessResponse.swift @@ -27,3 +27,7 @@ public struct SuccessResponse: Decodable, Sendable { public let data: T? public let error: ApiErrorResponse? } + +public struct EmptyResponse: Codable, Equatable, Sendable { + public init() {} +} diff --git a/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift b/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift index 54b9367..4eba539 100644 --- a/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift +++ b/Projects/Infra/DoriNetworkImpl/Sources/AuthInterceptor.swift @@ -12,11 +12,36 @@ import DoriNetwork public final class AuthInterceptor: RequestInterceptor { private let authorizationKey = "Authorization" private let tokenStore: any AuthTokenStoring - + private let maxRetryCount = 1 + private let coordinator = RefreshCoordinator() + private let session: Session + public init(tokenStore: any AuthTokenStoring) { self.tokenStore = tokenStore + self.session = Session() } - + + private actor RefreshCoordinator { + private var refreshTask: Task? + + func refresh(with refreshTokens: @escaping @Sendable () async -> Bool) async -> Bool { + if let existingTask = refreshTask { + return await existingTask.value + } + + let task = Task { @MainActor in + await refreshTokens() + } + + refreshTask = task + let result = await task.value + refreshTask = nil + + return result + } + + } + public func adapt( _ urlRequest: URLRequest, for session: Session, @@ -24,20 +49,86 @@ public final class AuthInterceptor: RequestInterceptor { ) { var request = urlRequest let accessToken = tokenStore.load().accessToken - + if let accessToken, !accessToken.isEmpty { - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: authorizationKey) + request.setValue( + "Bearer \(accessToken)", + forHTTPHeaderField: authorizationKey + ) } - + completion(.success(request)) } - + public func retry( _ request: Request, for session: Session, dueTo error: any Error, - completion: @escaping @Sendable (RetryResult) -> Void - ) { - completion(.doNotRetryWithError(error)) + completion: @escaping @Sendable (RetryResult) -> Void) { + print(#function) + guard let response = request.task?.response as? HTTPURLResponse, + response.statusCode == 401 else { + completion(.doNotRetryWithError(error)) + return + } + + guard request.retryCount < maxRetryCount else { + print("⚠️ Max retry count reached. Logging out.") + completion(.doNotRetry) + handleLogout() + return + } + + Task { @MainActor in + let success = await coordinator.refresh { [weak self] in + guard let self = self else { return false } + return await self.refreshTokens() + } + + print("retry is success?: \(success)") + if success { + completion(.retry) + } else { + completion(.doNotRetry) + handleLogout() + } + } + } + + private func refreshTokens() async -> Bool { + let tokens = tokenStore.load() + guard let refreshToken = tokens.refreshToken else { + return false + } + + guard let request = try? RefreshEndpoint(refreshToken: refreshToken).createURLRequest() else { + return false + } + + do { + let tokenResponse = try await session.request(request) + .validate() + .serializingDecodable(SuccessResponse.self) + .value + + guard let tokenData = tokenResponse.data else { + return false + } + + try? tokenStore.save( + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken + ) + + return true + } catch { + return false + } + } + + private func handleLogout() { + try? tokenStore.clear() } + } + diff --git a/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift b/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift index cb76177..949fee0 100644 --- a/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift +++ b/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift @@ -77,6 +77,8 @@ public final class NetworkServiceImpl: NetworkService { return .delete case .PATCH: return .patch + @unknown default: + fatalError() } } @@ -122,3 +124,4 @@ public final class NetworkServiceImpl: NetworkService { } } } + diff --git a/Projects/Platform/Keychain/Sources/KeychainError.swift b/Projects/Platform/Keychain/Sources/KeychainError.swift index c470cef..1446e7b 100644 --- a/Projects/Platform/Keychain/Sources/KeychainError.swift +++ b/Projects/Platform/Keychain/Sources/KeychainError.swift @@ -25,7 +25,7 @@ extension KeychainError: LocalizedError { case .invalidData: return "유효하지 않은 데이터입니다." case .unexpectedPasswordData: - return "암호화된 데이터를 얻기 위해 예상치 못한 오류가 발생했습니다." + return "암호화된 데이터를 얻기 위해 예상치 못한 오류가 발생했습니다." case .unexpected(let status): return "Keychain 에러: \(status)" }