From d00d32c7c463a26cd1b9fbb698de169f53c966c1 Mon Sep 17 00:00:00 2001 From: kangddong Date: Fri, 27 Mar 2026 16:16:36 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20#33=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPage/Sources/DoriToggleSwitch.swift | 48 +++++ .../MyPage/Sources/MyPageFeature.swift | 33 +++- .../Feature/MyPage/Sources/MyPageView.swift | 24 +++ .../Sources/NotificationSettingsFeature.swift | 88 +++++++++ .../Sources/NotificationSettingsView.swift | 168 ++++++++++++++++++ 5 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift create mode 100644 Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift create mode 100644 Projects/Feature/MyPage/Sources/NotificationSettingsView.swift diff --git a/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift b/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift new file mode 100644 index 0000000..2257b2f --- /dev/null +++ b/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift @@ -0,0 +1,48 @@ +// +// DoriToggleSwitch.swift +// Dori-iOS +// +// Created by 강동영 on 3/19/26. +// + +import DoriDesignSystem +import SwiftUI + +struct DoriToggleSwitch: View { + @Binding var isOn: Bool + + private let width: CGFloat = 26 + private let height: CGFloat = 15 + private let thumbSize: CGFloat = 13 + + var body: some View { + ZStack { + Capsule() + .fill(isOn ? UIAsset.Colors.main.color : UIAsset.Colors.grey300.color) + .frame(width: width, height: height) + .animation(.easeInOut(duration: 0.2), value: isOn) + + Circle() + .fill(UIAsset.Colors.doriWhite.color) + .frame(width: thumbSize, height: thumbSize) + .shadow(color: .black.opacity(0.15), radius: 2, x: 0, y: 1) + .offset(x: isOn ? (width / 2 - thumbSize / 2 - 2) : -(width / 2 - thumbSize / 2 - 2)) + .animation(.easeInOut(duration: 0.2), value: isOn) + } + .frame(width: width, height: height) + .onTapGesture { + isOn.toggle() + } + } +} + +#Preview { + @Previewable @State var isOn = false + + VStack(spacing: 24) { + DoriToggleSwitch(isOn: $isOn) + DoriToggleSwitch(isOn: .constant(true)) + DoriToggleSwitch(isOn: .constant(false)) + } + .padding() +} diff --git a/Projects/Feature/MyPage/Sources/MyPageFeature.swift b/Projects/Feature/MyPage/Sources/MyPageFeature.swift index 8e6e45e..4dbeaeb 100644 --- a/Projects/Feature/MyPage/Sources/MyPageFeature.swift +++ b/Projects/Feature/MyPage/Sources/MyPageFeature.swift @@ -9,6 +9,7 @@ import ComposableArchitecture import DoriDesignSystem import DoriNetwork import Foundation +import PlatformKeychain @Reducer public struct MyPageFeature { @@ -28,25 +29,30 @@ public struct MyPageFeature { public var isLogoutAlertPresented: Bool public var isWithdrawAlertPresented: Bool public var toastItem: DoriToast? + public var notificationSettings: NotificationSettingsFeature.State public init( isLoading: Bool = false, isLogoutAlertPresented: Bool = false, isWithdrawAlertPresented: Bool = false, - toastItem: DoriToast? = nil + toastItem: DoriToast? = nil, + notificationSettings: NotificationSettingsFeature.State = NotificationSettingsFeature.State() ) { self.navigationPath = [] self.isLoading = isLoading self.isLogoutAlertPresented = isLogoutAlertPresented self.isWithdrawAlertPresented = isWithdrawAlertPresented self.toastItem = toastItem + self.notificationSettings = notificationSettings } } public enum Action: Equatable, Sendable { case onAppear case privacyPolicyTapped + case notificationSettingsTapped case navigationPathChanged([Route]) + case notificationSettings(NotificationSettingsFeature.Action) case logoutButtonTapped case withdrawButtonTapped @@ -73,6 +79,7 @@ public struct MyPageFeature { public enum Route: Hashable, Sendable { case privacyPolicy + case notificationSettings } public func reduce(into state: inout State, action: Action) -> Effect { @@ -84,10 +91,23 @@ public struct MyPageFeature { state.navigationPath.append(.privacyPolicy) return .none + case .notificationSettingsTapped: + state.navigationPath.append(.notificationSettings) + return .none + case .navigationPathChanged(let path): state.navigationPath = path return .none + case .notificationSettings(.delegate(.didTapBack)): + state.navigationPath.removeAll(where: { $0 == .notificationSettings }) + return .none + + case .notificationSettings(let notifAction): + return NotificationSettingsFeature() + .reduce(into: &state.notificationSettings, action: notifAction) + .map(Action.notificationSettings) + case .logoutButtonTapped: state.isLogoutAlertPresented = true return .none @@ -264,7 +284,7 @@ extension MyPageAPIClient: TestDependencyKey { public extension MyPageAPIClient { static func live( networkService: any NetworkService, - tokenStore: any AuthTokenStoring + tokenStore: KeychainAuthTokenStore ) -> Self { Self( logout: { @@ -289,6 +309,15 @@ public extension MyPageAPIClient { throw MyPageAPIClientError.invalidResponse } + if let fcmToken = tokenStore.loadFCMToken() { + let fcmEndpoint = DeleteFCMTokenEndpoint(token: fcmToken) + _ = try? await networkService.request( + fcmEndpoint, + responseType: SuccessResponse.self + ) + tokenStore.deleteFCMToken() + } + try tokenStore.clear() }, withdraw: { diff --git a/Projects/Feature/MyPage/Sources/MyPageView.swift b/Projects/Feature/MyPage/Sources/MyPageView.swift index db4839f..6dab2a2 100644 --- a/Projects/Feature/MyPage/Sources/MyPageView.swift +++ b/Projects/Feature/MyPage/Sources/MyPageView.swift @@ -31,6 +31,7 @@ public struct MyPageView: View { VStack(alignment: .leading, spacing: 24) { settingInfoView accountInfoView + notificationInfoView Spacer() } @@ -81,6 +82,13 @@ public struct MyPageView: View { navigationTitle: "개인정보처리방침", url: Self.privacyPolicyURL ) + case .notificationSettings: + NotificationSettingsView( + store: store.scope( + state: \.notificationSettings, + action: \.notificationSettings + ) + ) } } } @@ -130,8 +138,24 @@ public struct MyPageView: View { NavigationRow("탈퇴하기") { store.send(.withdrawButtonTapped) } + + Divider() } } + + private var notificationInfoView: some View { + VStack(alignment: .leading, spacing: 16) { + Text("알림") + .pretendard(.subtitle(.m2)) + .foregroundStyle(.grey600) + + NavigationRow("앱 알림 설정") { + store.send(.notificationSettingsTapped) + } + + } + } + private var logoutAlertBinding: Binding { Binding( get: { store.isLogoutAlertPresented }, diff --git a/Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift b/Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift new file mode 100644 index 0000000..d41df8f --- /dev/null +++ b/Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift @@ -0,0 +1,88 @@ +// +// NotificationSettingsFeature.swift +// Dori-iOS +// +// Created by 강동영 on 3/19/26. +// + +import ComposableArchitecture + +@Reducer +public struct NotificationSettingsFeature { + public init() {} + + @ObservableState + public struct State: Equatable, Sendable { + public var isAllPushEnabled: Bool + public var isDoriAlertEnabled: Bool + public var isRecordReminderEnabled: Bool + public var isMonthlyEnabled: Bool + public var isRelationBalanceEnabled: Bool + public var isActivitySummaryEnabled: Bool + + public init( + isAllPushEnabled: Bool = false, + isDoriAlertEnabled: Bool = false, + isRecordReminderEnabled: Bool = false, + isMonthlyEnabled: Bool = false, + isRelationBalanceEnabled: Bool = false, + isActivitySummaryEnabled: Bool = false + ) { + self.isAllPushEnabled = isAllPushEnabled + self.isDoriAlertEnabled = isDoriAlertEnabled + self.isRecordReminderEnabled = isRecordReminderEnabled + self.isMonthlyEnabled = isMonthlyEnabled + self.isRelationBalanceEnabled = isRelationBalanceEnabled + self.isActivitySummaryEnabled = isActivitySummaryEnabled + } + } + + public enum Action: Equatable, Sendable { + case allPushToggled(Bool) + case doriAlertToggled(Bool) + case recordReminderToggled(Bool) + case monthlyToggled(Bool) + case relationBalanceToggled(Bool) + case activitySummaryToggled(Bool) + case backButtonTapped + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case didTapBack + } + } + + public func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .allPushToggled(let value): + state.isAllPushEnabled = value + return .none + + case .doriAlertToggled(let value): + state.isDoriAlertEnabled = value + return .none + + case .recordReminderToggled(let value): + state.isRecordReminderEnabled = value + return .none + + case .monthlyToggled(let value): + state.isMonthlyEnabled = value + return .none + + case .relationBalanceToggled(let value): + state.isRelationBalanceEnabled = value + return .none + + case .activitySummaryToggled(let value): + state.isActivitySummaryEnabled = value + return .none + + case .backButtonTapped: + return .send(.delegate(.didTapBack)) + + case .delegate: + return .none + } + } +} diff --git a/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift b/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift new file mode 100644 index 0000000..e688264 --- /dev/null +++ b/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift @@ -0,0 +1,168 @@ +// +// NotificationSettingsView.swift +// Dori-iOS +// +// Created by 강동영 on 3/19/26. +// + +import ComposableArchitecture +import DoriDesignSystem +import SwiftUI + +struct NotificationSettingsView: View { + @Bindable var store: StoreOf + + var body: some View { + ZStack { + UIAsset.Colors.doriWhite.color + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // 전체 푸시 수신 (Type 1: HStack { VStack { title, description }, switch }) + notificationRowWithDescription( + title: "앱 알림 받기", + description: "알림이 꺼져 있어요\n알림을 받으려면 켜주세요", + isOn: $store.isAllPushEnabled.sending(\.allPushToggled) + ) + + VStack(alignment: .leading, spacing: 24) { + Divider() + + // 도리 알림 (Type 1) + notificationRowWithDescription( + title: "도리 알림", + description: "등록한 일정에 맞춰 알려드려요", + isOn: $store.isDoriAlertEnabled.sending(\.doriAlertToggled) + ) + + Divider() + + // 기록 알림 (Type 3: VStack { title, description } - 섹션 헤더) + notificationSectionHeader( + title: "기록 알림", + description: "기록을 도와주는 알림이에요" + ) + + // 기록 리마인드 (Type 2: HStack { title, switch }) + notificationRowSimple( + title: "기록 리마인드", + isOn: $store.isRecordReminderEnabled.sending(\.recordReminderToggled) + ) + + // 월간 요약 (Type 2) + notificationRowSimple( + title: "월간 요약", + isOn: $store.isMonthlyEnabled.sending(\.monthlyToggled) + ) + + Divider() + + // 관계 인사이트 알림 (Type 3 - 섹션 헤더) + notificationSectionHeader( + title: "관계 인사이트 알림", + description: "관계 흐름을 분석해 알려드려요" + ) + + // 관계 균형 알림 (Type 2) + notificationRowSimple( + title: "관계 균형 알림", + isOn: $store.isRelationBalanceEnabled.sending(\.relationBalanceToggled) + ) + + // 활동 요약 알림 (Type 2) + notificationRowSimple( + title: "활동 요약 알림", + isOn: $store.isActivitySummaryEnabled.sending(\.activitySummaryToggled) + ) + } + .overlay { + if !store.isAllPushEnabled { + UIAsset.Colors.doriWhite.color + .opacity(0.6) + .allowsHitTesting(true) + } + } + } + .padding(.top, 24) + .padding(.leading, 16) + .padding(.trailing, 20) + } + } + .doriNavigationBar( + DoriNavigationBarConfig.backWithTitle("앱 알림 설정") { + store.send(.backButtonTapped) + } + ) + } + + // MARK: - Type 1: HStack { VStack { title, description }, Spacer, switch } + + @ViewBuilder + private func notificationRowWithDescription( + title: String, + description: String, + isOn: Binding + ) -> some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .pretendard(.body(.m3)) + .foregroundStyle(.doriBlack) + Text(description) + .pretendard(.body(.r6)) + .foregroundStyle(.grey600) + } + + Spacer() + + DoriToggleSwitch(isOn: isOn) + } + } + + // MARK: - Type 2: HStack { title, Spacer, switch } + + @ViewBuilder + private func notificationRowSimple( + title: String, + isOn: Binding + ) -> some View { + HStack { + Text(title) + .pretendard(.body(.r3)) + .foregroundStyle(.doriBlack) + + Spacer() + + DoriToggleSwitch(isOn: isOn) + } + } + + // MARK: - Type 3: VStack { title, description } (섹션 헤더, 스위치 없음) + + @ViewBuilder + private func notificationSectionHeader( + title: String, + description: String + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .pretendard(.body(.m3)) + .foregroundStyle(.doriBlack) + Text(description) + .pretendard(.body(.r6)) + .foregroundStyle(.grey600) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview { + NavigationStack { + NotificationSettingsView( + store: Store(initialState: NotificationSettingsFeature.State()) { + NotificationSettingsFeature() + } + ) + } +} From bc3ee6e38541e5b43da12c885094d63e62cdd5a6 Mon Sep 17 00:00:00 2001 From: kangddong Date: Fri, 27 Mar 2026 16:16:46 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20FCM=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D/=EC=82=AD=EC=A0=9C=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20Firebase=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Projects/App/Project.swift | 1 + Projects/App/Sources/AppDelegate.swift | 36 ++++++++ Projects/App/Sources/DoriApp.swift | 14 +++ Projects/Feature/Project.swift | 1 + .../Sources/Endpoints/FCMEndpoints.swift | 38 ++++++++ Projects/Platform/FCM/Sources/FCMClient.swift | 52 +++++++++++ .../Platform/FCM/Sources/FCMService.swift | 89 +++++++++++++++++++ .../Keychain/Sources/DoriKeychainKey.swift | 2 + .../Sources/KeychainAuthTokenStore.swift | 12 +++ Projects/Platform/Project.swift | 8 ++ Tuist/Package.swift | 1 + .../DoriTargets.swift | 3 + .../InfoPlist+Extension.swift | 2 + .../TargetDependency+Extension.swift | 4 +- 15 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 Projects/App/Sources/AppDelegate.swift create mode 100644 Projects/Infra/DoriNetwork/Sources/Endpoints/FCMEndpoints.swift create mode 100644 Projects/Platform/FCM/Sources/FCMClient.swift create mode 100644 Projects/Platform/FCM/Sources/FCMService.swift diff --git a/.gitignore b/.gitignore index fa2d964..53afd51 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ Derived/ *.xcodeproj *.xcworkspace .claude/worktrees +GoogleService-Info.plist diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 6f83472..0e010c6 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -24,6 +24,7 @@ let project = Project.dori( DoriModules.networkImpl.module.projectDependency, DoriModules.kakaoAuth.module.projectDependency, DoriModules.keychain.module.projectDependency, + DoriModules.fcm.module.projectDependency, DoriModules.designSystem.module.projectDependency, DoriModules.core.module.projectDependency, .external(.composableArchitecture) diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift new file mode 100644 index 0000000..48ec993 --- /dev/null +++ b/Projects/App/Sources/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// Dori-iOS +// +// Created by 강동영 on 3/25/26. +// + +import UIKit +import PlatformFCM + +final class AppDelegate: NSObject, UIApplicationDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + FCMService.shared.configure() + return true + } + + // MARK: - APNs Token + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + FCMService.shared.setAPNSToken(deviceToken) + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + // 실제 기기에서만 APNs 등록 가능 — 시뮬레이터 실패는 무시 + } +} diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index 5faf3a5..abfc21b 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -17,9 +17,11 @@ import FeatureHistory import FeatureCalendar import PlatformKakaoAuth import PlatformKeychain +import PlatformFCM @main struct DoriApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate let store: StoreOf #if DEBUG private let debugLaunchRoute: DebugLaunchRoute? @@ -82,6 +84,15 @@ struct DoriApp: App { FontManager.registerAllFonts() KakaoSDKHandler.initializeFromMainBundle() + + FCMService.shared.tokenRefreshHandler = { token in + try? tokenStore.saveFCMToken(token) + let endpoint = RegisterFCMTokenEndpoint(token: token) + _ = try? await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + } } var body: some Scene { @@ -109,6 +120,9 @@ struct DoriApp: App { .onOpenURL { url in _ = KakaoSDKHandler.handleOpenURL(url) } + .task { + await FCMService.shared.requestAuthorization() + } } } diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift index 319fb14..7303f1d 100644 --- a/Projects/Feature/Project.swift +++ b/Projects/Feature/Project.swift @@ -61,6 +61,7 @@ let project = Project.dori( dependencies: [ DoriModules.designSystem.module.projectDependency, DoriModules.network.module.projectDependency, + DoriModules.keychain.module.projectDependency, .external(.composableArchitecture) ] ), diff --git a/Projects/Infra/DoriNetwork/Sources/Endpoints/FCMEndpoints.swift b/Projects/Infra/DoriNetwork/Sources/Endpoints/FCMEndpoints.swift new file mode 100644 index 0000000..c35fba5 --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Endpoints/FCMEndpoints.swift @@ -0,0 +1,38 @@ +// +// FCMEndpoints.swift +// Dori-iOS +// +// Created by 강동영 on 3/27/26. +// + +import Foundation + +public struct RegisterFCMTokenEndpoint: Endpoint { + public let baseURL: String = NetworkConfig.baseURL + public let path: String = "/fcm/token" + public let method: HTTPMethod = .POST + public let headers: [String: String] = [:] + public let queryParameters: [String: String] = [:] + public let body: Data? + + public init(token: String) { + self.body = try? JSONEncoder().encode(FCMTokenRequest(token: token)) + } +} + +public struct DeleteFCMTokenEndpoint: Endpoint { + public let baseURL: String = NetworkConfig.baseURL + public let path: String = "/fcm/token" + public let method: HTTPMethod = .DELETE + public let headers: [String: String] = [:] + public let queryParameters: [String: String] + public let body: Data? = nil + + public init(token: String) { + self.queryParameters = ["token": token] + } +} + +private struct FCMTokenRequest: Encodable { + let token: String +} diff --git a/Projects/Platform/FCM/Sources/FCMClient.swift b/Projects/Platform/FCM/Sources/FCMClient.swift new file mode 100644 index 0000000..dfbfab0 --- /dev/null +++ b/Projects/Platform/FCM/Sources/FCMClient.swift @@ -0,0 +1,52 @@ +// +// FCMClient.swift +// Dori-iOS +// +// Created by 강동영 on 3/25/26. +// + +import ComposableArchitecture + +/// FCM 관련 기능을 TCA Dependency로 노출하는 클라이언트. +/// +/// - `requestPermissionAndRegister`: APNs 권한 요청 + 원격 알림 등록 +/// - `uploadFCMTokenToServer`: FCM 토큰을 서버에 전송하는 스텁 (API 연동 시 구현) +@DependencyClient +public struct FCMClient: Sendable { + /// APNs 알림 권한을 요청하고 승인되면 원격 알림을 등록한다. + public var requestPermissionAndRegister: @Sendable () async -> Void = {} + + /// FCM 토큰을 서버에 등록한다. + /// + /// - Note: 서버 FCM 토큰 등록 API 연동 전까지 빈 스텁으로 유지한다. + /// 연동 시 `networkService.registerFCMToken(token)` 형태로 구현한다. + public var uploadFCMTokenToServer: @Sendable (_ token: String) async throws -> Void = { _ in } +} + +// MARK: - DependencyKey + +extension FCMClient: DependencyKey { + public static let liveValue = Self( + requestPermissionAndRegister: { + await FCMService.shared.requestAuthorization() + }, + uploadFCMTokenToServer: { token in + // TODO: 서버 FCM 토큰 등록 API 연동 + // 예시: try await networkService.registerFCMToken(token) + } + ) + + public static let testValue = Self( + requestPermissionAndRegister: {}, + uploadFCMTokenToServer: { _ in } + ) +} + +// MARK: - DependencyValues + +public extension DependencyValues { + var fcmClient: FCMClient { + get { self[FCMClient.self] } + set { self[FCMClient.self] = newValue } + } +} diff --git a/Projects/Platform/FCM/Sources/FCMService.swift b/Projects/Platform/FCM/Sources/FCMService.swift new file mode 100644 index 0000000..744a9da --- /dev/null +++ b/Projects/Platform/FCM/Sources/FCMService.swift @@ -0,0 +1,89 @@ +// +// FCMService.swift +// Dori-iOS +// +// Created by 강동영 on 3/25/26. +// + +import UIKit +import UserNotifications +import FirebaseCore +import FirebaseMessaging + +/// Firebase Cloud Messaging 전담 서비스 객체. +/// +/// 역할: +/// - Firebase 초기화 +/// - APNs 권한 요청 및 원격 알림 등록 +/// - FCM 토큰 수신 후 `tokenRefreshHandler` 를 통해 외부(서버 업로드 등)로 전달 +@MainActor +public final class FCMService: NSObject { + + public static let shared = FCMService() + + /// FCM 토큰이 새로 발급/갱신됐을 때 호출되는 핸들러. + /// 서버 FCM 토큰 등록 API 연동 시 이 핸들러에 구현체를 주입한다. + public var tokenRefreshHandler: ((_ token: String) async -> Void)? + + private override init() { + super.init() + } + + // MARK: - Setup + + /// Firebase를 초기화하고 Messaging / UNUserNotificationCenter 델리게이트를 설정한다. + /// `AppDelegate.application(_:didFinishLaunchingWithOptions:)` 에서 호출한다. + public func configure() { + FirebaseApp.configure() + Messaging.messaging().delegate = self + UNUserNotificationCenter.current().delegate = self + } + + // MARK: - Authorization + + /// APNs 알림 권한을 요청하고 승인된 경우 원격 알림을 등록한다. + public func requestAuthorization() async { + do { + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .badge, .sound]) + guard granted else { return } + UIApplication.shared.registerForRemoteNotifications() + } catch { + // 권한 거부 또는 시스템 오류 — 조용히 처리 + } + } + + // MARK: - APNs Token + + /// AppDelegate로부터 APNs 디바이스 토큰을 전달받아 Firebase Messaging에 등록한다. + public func setAPNSToken(_ deviceToken: Data) { + Messaging.messaging().apnsToken = deviceToken + } +} + +// MARK: - MessagingDelegate + +extension FCMService: MessagingDelegate { + /// FCM 토큰이 새로 발급되거나 갱신될 때 호출된다. + public nonisolated func messaging( + _ messaging: Messaging, + didReceiveRegistrationToken fcmToken: String? + ) { + guard let token = fcmToken else { return } + Task { @MainActor [weak self] in + await self?.tokenRefreshHandler?(token) + } + } +} + +// MARK: - UNUserNotificationCenterDelegate + +extension FCMService: UNUserNotificationCenterDelegate { + /// 앱이 포그라운드 상태일 때 수신된 알림을 배너·사운드·뱃지로 표시한다. + public nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + return [.banner, .sound, .badge] + } +} diff --git a/Projects/Platform/Keychain/Sources/DoriKeychainKey.swift b/Projects/Platform/Keychain/Sources/DoriKeychainKey.swift index a02252b..03a4df8 100644 --- a/Projects/Platform/Keychain/Sources/DoriKeychainKey.swift +++ b/Projects/Platform/Keychain/Sources/DoriKeychainKey.swift @@ -12,11 +12,13 @@ public enum DoriKeychainKey: Sendable { case accessToken case refreshToken + case fcmToken public var rawValue: String { switch self { case .accessToken: return "access_token" case .refreshToken: return "refresh_token" + case .fcmToken: return "fcm_token" } } } diff --git a/Projects/Platform/Keychain/Sources/KeychainAuthTokenStore.swift b/Projects/Platform/Keychain/Sources/KeychainAuthTokenStore.swift index 92c5cd7..30a2a44 100644 --- a/Projects/Platform/Keychain/Sources/KeychainAuthTokenStore.swift +++ b/Projects/Platform/Keychain/Sources/KeychainAuthTokenStore.swift @@ -37,6 +37,18 @@ public struct KeychainAuthTokenStore: AuthTokenStoring { public func exists() throws -> Bool { try contains(.accessToken) } + + public func saveFCMToken(_ token: String) throws { + try set(token, for: .fcmToken) + } + + public func loadFCMToken() -> String? { + try? string(for: .fcmToken) + } + + public func deleteFCMToken() { + _ = try? delete(.fcmToken) + } } private extension KeychainAuthTokenStore { diff --git a/Projects/Platform/Project.swift b/Projects/Platform/Project.swift index d758a23..c32a967 100644 --- a/Projects/Platform/Project.swift +++ b/Projects/Platform/Project.swift @@ -26,5 +26,13 @@ let project = Project.dori( DoriModules.network.module.projectDependency, ] ), + .doriFramework( + DoriModules.fcm.module, + dependencies: [ + .external(.firebaseCore), + .external(.firebaseMessaging), + .external(.composableArchitecture), + ] + ), ] ) diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 7e02091..d95e174 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -23,5 +23,6 @@ let package = Package( .package(url: "https://github.com/Swinject/Swinject.git", from: "2.9.1"), .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.11.1"), .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.0.0"), + .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "11.0.0"), ] ) diff --git a/Tuist/ProjectDescriptionHelpers/DoriTargets.swift b/Tuist/ProjectDescriptionHelpers/DoriTargets.swift index 2a158f3..d94469f 100644 --- a/Tuist/ProjectDescriptionHelpers/DoriTargets.swift +++ b/Tuist/ProjectDescriptionHelpers/DoriTargets.swift @@ -63,6 +63,7 @@ public enum DoriModules: CaseIterable, Sendable { case networkImpl case kakaoAuth case keychain + case fcm case onboarding case calendar case history @@ -83,6 +84,8 @@ public enum DoriModules: CaseIterable, Sendable { DoriModule(name: "PlatformKakaoAuth", layer: .platform, directoryName: "KakaoAuth") case .keychain: DoriModule(name: "PlatformKeychain", layer: .platform, directoryName: "Keychain") + case .fcm: + DoriModule(name: "PlatformFCM", layer: .platform, directoryName: "FCM") case .onboarding: DoriModule(name: "FeatureOnboarding", layer: .feature, directoryName: "Onboarding") case .calendar: diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index 514035d..7d8d539 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -14,6 +14,8 @@ extension InfoPlist { "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", "Appearance": "Light", "ITSAppUsesNonExemptEncryption": .boolean(false), + "FirebaseAppDelegateProxyEnabled": .boolean(false), + "FirebaseMessagingAutoInitEnabled": .boolean(true), "CFBundleURLTypes": [ [ "CFBundleTypeRole": "Editor", diff --git a/Tuist/ProjectDescriptionHelpers/TargetDependency+Extension.swift b/Tuist/ProjectDescriptionHelpers/TargetDependency+Extension.swift index b57e567..a7576f7 100644 --- a/Tuist/ProjectDescriptionHelpers/TargetDependency+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/TargetDependency+Extension.swift @@ -12,7 +12,9 @@ public enum DoriDependency: String { case kakaoSDKCommon case kakaoSDKAuth case kakaoSDKUser - + case firebaseCore = "FirebaseCore" + case firebaseMessaging = "FirebaseMessaging" + var name: String { rawValue } From 42ebf708e776bd18876e2ea6c31474d73c071910 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 28 Apr 2026 14:26:55 +0900 Subject: [PATCH 03/19] =?UTF-8?q?chore:=20#42=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20entitlements=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug/Release 환경 변수 분리, BuildEnvironment 도입, APS entitlements 추가. Co-Authored-By: Claude Opus 4.7 --- Projects/App/DoriAppDebug.entitlements | 8 ++++ Projects/App/Project.swift | 15 ++++++- Projects/App/Resources/DoriApp.entitlements | 8 ++++ .../DoriCore/Sources/BuildEnvironment.swift | 39 +++++++++++++++++++ Projects/Platform/Project.swift | 3 +- .../Settings+Extension.swift | 39 +++++++++++++++++-- .../Target+Extension.swift | 6 ++- 7 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 Projects/App/DoriAppDebug.entitlements create mode 100644 Projects/App/Resources/DoriApp.entitlements create mode 100644 Projects/Core/DoriCore/Sources/BuildEnvironment.swift diff --git a/Projects/App/DoriAppDebug.entitlements b/Projects/App/DoriAppDebug.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/Projects/App/DoriAppDebug.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 0e010c6..97b0dd3 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -13,7 +13,7 @@ let project = Project.dori( .app( name: "DoriApp", bundleId: Environment.App.baseBundleId, - resources: [.glob(pattern: "Resources/**", excluding: ["Resources/info.plist"])], + resources: [.glob(pattern: "Resources/**", excluding: ["Resources/info.plist", "Resources/*.entitlements"])], dependencies: [ DoriModules.onboarding.module.projectDependency, DoriModules.addDori.module.projectDependency, @@ -29,6 +29,19 @@ let project = Project.dori( DoriModules.core.module.projectDependency, .external(.composableArchitecture) ], + entitlements: .file(path: "Resources/DoriApp.entitlements") + ), + .target( + name: "DoriAppUITests", + destinations: .iOS, + product: .uiTests, + bundleId: "\(Environment.App.baseBundleId).UITests", + deploymentTargets: .iOS(Environment.deploymentTarget), + sources: ["UITests/**"], + dependencies: [ + .target(name: "DoriApp") + ], + settings: .testSettings ), ] ) diff --git a/Projects/App/Resources/DoriApp.entitlements b/Projects/App/Resources/DoriApp.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/Projects/App/Resources/DoriApp.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/Projects/Core/DoriCore/Sources/BuildEnvironment.swift b/Projects/Core/DoriCore/Sources/BuildEnvironment.swift new file mode 100644 index 0000000..1187b9b --- /dev/null +++ b/Projects/Core/DoriCore/Sources/BuildEnvironment.swift @@ -0,0 +1,39 @@ +// +// BuildEnvironment.swift +// Dori-iOS +// +// Created by 강동영 on 4/18/26. +// + +import Foundation + +public enum BuildEnvironment: String, Sendable { + case debug + case qa + case release + + public static let current: BuildEnvironment = { + #if DEBUG + return .debug + #elseif QA + return .qa + #else + return .release + #endif + }() + + public var isTestingEnabled: Bool { + switch self { + case .debug, .qa: return true + case .release: return false + } + } + + public var displayName: String { + switch self { + case .debug: return "DEBUG" + case .qa: return "QA" + case .release: return "RELEASE" + } + } +} diff --git a/Projects/Platform/Project.swift b/Projects/Platform/Project.swift index c32a967..0d92c8e 100644 --- a/Projects/Platform/Project.swift +++ b/Projects/Platform/Project.swift @@ -32,7 +32,8 @@ let project = Project.dori( .external(.firebaseCore), .external(.firebaseMessaging), .external(.composableArchitecture), - ] + ], + settings: .frameworkSettingsWithObjC ), ] ) diff --git a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift index cf63d1d..7eb04de 100644 --- a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift @@ -37,6 +37,36 @@ public extension Settings { ] ) + /// ObjC 카테고리 강제 로딩이 필요한 프레임워크용 설정 (Firebase 등) + static let frameworkSettingsWithObjC: Settings = .settings( + base: [ + "SKIP_INSTALL": "YES", + "DEFINES_MODULE": "YES", + "ENABLE_BITCODE": "NO", + "IPHONEOS_DEPLOYMENT_TARGET": .string(Environment.deploymentTarget), + "SWIFT_VERSION": "6.0", + "CLANG_ENABLE_MODULES": "YES", + "OTHER_LDFLAGS": .array(["$(inherited)", "-ObjC"]), + ], + configurations: [ + .debug( + name: .debug, + settings: [ + "ENABLE_TESTABILITY": "YES", + "SWIFT_OPTIMIZATION_LEVEL": "-Onone" + ] + ), + .release( + name: .release, + settings: [ + "ENABLE_TESTABILITY": "NO", + "SWIFT_OPTIMIZATION_LEVEL": "-O", + "SWIFT_COMPILATION_MODE": "wholemodule" + ] + ) + ] + ) + /// 테스트용 기본 설정 static let testSettings: Settings = .settings( base: [ @@ -63,6 +93,7 @@ public extension Settings { "ENABLE_BITCODE": "NO", "IPHONEOS_DEPLOYMENT_TARGET": .string(Environment.deploymentTarget), "SWIFT_VERSION": "6.0", + "OTHER_LDFLAGS": .array(["$(inherited)", "-ObjC"]), ] let debugSettings: [String: SettingValue] = [ @@ -71,15 +102,17 @@ public extension Settings { "GCC_OPTIMIZATION_LEVEL": "0", "SWIFT_OPTIMIZATION_LEVEL": "-Onone", "DEBUG_INFORMATION_FORMAT": "dwarf", - "GCC_PREPROCESSOR_DEFINITIONS": .array(["DEBUG=1"]) + "GCC_PREPROCESSOR_DEFINITIONS": .array(["DEBUG=1"]), + "SWIFT_ACTIVE_COMPILATION_CONDITIONS": .array(["$(inherited)", "DEBUG"]) ] - + let releaseSettings: [String: SettingValue] = [ "PRODUCT_NAME": .string(BuildConfiguration.release.appName), "SWIFT_OPTIMIZATION_LEVEL": "-O", "ENABLE_TESTABILITY": "NO", "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", - "SWIFT_COMPILATION_MODE": "wholemodule" + "SWIFT_COMPILATION_MODE": "wholemodule", + "SWIFT_ACTIVE_COMPILATION_CONDITIONS": .array(["$(inherited)"]) ] return .settings( diff --git a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift index a392906..786d04b 100644 --- a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift @@ -11,7 +11,8 @@ public extension Target { static func doriFramework( _ module: DoriModule, dependencies: [TargetDependency] = [], - hasResources: Bool = false + hasResources: Bool = false, + settings: Settings = .frameworkSettings ) -> Target { .target( name: module.name, @@ -22,7 +23,7 @@ public extension Target { sources: ["\(module.localPath)/Sources/**"], resources: hasResources ? ["\(module.localPath)/Resources/**"] : nil, dependencies: dependencies, - settings: .frameworkSettings + settings: settings ) } @@ -61,6 +62,7 @@ public extension Target { infoPlist: infoPlist, sources: sources, resources: resources, + entitlements: entitlements, dependencies: dependencies, settings: settings ) From e90256549f09116649deec3060a09566567d8075 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 28 Apr 2026 14:26:59 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20#42=20FCM/APNs=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EB=B6=80=ED=8A=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=9E=A9=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 진입점에서 FCM 토큰 등록 및 APNs 위임 연결. Co-Authored-By: Claude Opus 4.7 --- Projects/App/Sources/AppDelegate.swift | 4 +++- Projects/App/Sources/DoriApp.swift | 11 +++++++++++ Projects/Platform/FCM/Sources/FCMService.swift | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index 48ec993..9772f85 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -24,6 +24,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + print("APNS Token: \"\(token)\"") FCMService.shared.setAPNSToken(deviceToken) } @@ -31,6 +33,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { - // 실제 기기에서만 APNs 등록 가능 — 시뮬레이터 실패는 무시 + print("APNs 등록 실패: \(error.localizedDescription)") } } diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index abfc21b..b62cf22 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -77,6 +77,8 @@ struct DoriApp: App { networkService: networkService, tokenStore: tokenStore ) + $0.fcmPushTestAPIClient = .live(networkService: networkService) + $0.notificationSettingsAPIClient = .live(networkService: networkService) } storeBox.store = store @@ -128,11 +130,13 @@ struct DoriApp: App { #if DEBUG private struct DebugLaunchRoute { + private let environment: [String: String] private let route: String private let memo: String init?(environment: [String: String]) { guard let route = environment["DORI_DEBUG_ROUTE"] else { return nil } + self.environment = environment self.route = route self.memo = environment["DORI_DEBUG_MEMO"] ?? "" } @@ -146,6 +150,11 @@ private struct DebugLaunchRoute { AddDoriView( store: Store(initialState: configuredState) { AddDoriFeature() + } withDependencies: { + $0.addDoriAPIClient = .testValue + $0.userNotificationSettingsClient.isNotificationEnabled = { + environment["DORI_DEBUG_NOTIFICATION_ENABLED"] == "true" + } } ) } @@ -158,6 +167,8 @@ private struct DebugLaunchRoute { private var configuredState: AddDoriFeature.State { var state = AddDoriFeature.State() state.currentPage = 2 + state.searchQuery = environment["DORI_DEBUG_PARTNER_NAME"] ?? "김철수" + state.amountInput.text = environment["DORI_DEBUG_AMOUNT"] ?? "100000" state.memo = memo return state } diff --git a/Projects/Platform/FCM/Sources/FCMService.swift b/Projects/Platform/FCM/Sources/FCMService.swift index 744a9da..4e324cf 100644 --- a/Projects/Platform/FCM/Sources/FCMService.swift +++ b/Projects/Platform/FCM/Sources/FCMService.swift @@ -70,6 +70,7 @@ extension FCMService: MessagingDelegate { didReceiveRegistrationToken fcmToken: String? ) { guard let token = fcmToken else { return } + print("FCM Token: \"\(token)\"") Task { @MainActor [weak self] in await self?.tokenRefreshHandler?(token) } From d179a153a6faa6c787b65e6223bfb7e3220f6175 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 28 Apr 2026 14:27:03 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20#42=20FCM=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA용 FCMPushTestEndpoint 추가. Co-Authored-By: Claude Opus 4.7 --- .../Sources/Endpoints/FCMEndpoints.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Projects/Infra/DoriNetwork/Sources/Endpoints/FCMEndpoints.swift b/Projects/Infra/DoriNetwork/Sources/Endpoints/FCMEndpoints.swift index c35fba5..f903d8b 100644 --- a/Projects/Infra/DoriNetwork/Sources/Endpoints/FCMEndpoints.swift +++ b/Projects/Infra/DoriNetwork/Sources/Endpoints/FCMEndpoints.swift @@ -33,6 +33,28 @@ public struct DeleteFCMTokenEndpoint: Endpoint { } } +public struct FCMPushTestEndpoint: Endpoint { + public let baseURL: String = NetworkConfig.baseURL + public let path: String = "/fcm/test" + public let method: HTTPMethod = .POST + public let headers: [String: String] = [:] + public let queryParameters: [String: String] + public let body: Data? = nil + + public init( + userId: Int64, + title: String, + body: String + ) { + self.queryParameters = [ + "userId": String(userId), + "title": title, + "body": body + ] + } +} + private struct FCMTokenRequest: Encodable { let token: String } + From d3f9f6f270ecb7de14d4d5cb3ee9f35112a38f46 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 28 Apr 2026 14:27:07 +0900 Subject: [PATCH 06/19] =?UTF-8?q?chore:=20#42=20FCM=20=ED=91=B8=EC=8B=9C?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B0=8F=20UITests=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA용 FCM 푸시 테스트 화면과 UITests 타겟 자산 추가. Co-Authored-By: Claude Opus 4.7 --- .../App/UITests/Core/Base/TestUIBase.swift | 30 +++ .../Core/Extensions/XCTestExpectation+.swift | 18 ++ .../Core/Protocols/UITestProtocols.swift | 13 + .../UITests/Identifiers/TestIdentifier.swift | 27 ++ ...IScenarioAddDoriNotificationSettings.swift | 33 +++ .../TestPage/UIBase/UIBaseAddDoriView.swift | 41 +++ .../MyPage/Sources/FCMPushTestFeature.swift | 234 ++++++++++++++++++ .../MyPage/Sources/FCMPushTestView.swift | 117 +++++++++ 8 files changed, 513 insertions(+) create mode 100644 Projects/App/UITests/Core/Base/TestUIBase.swift create mode 100644 Projects/App/UITests/Core/Extensions/XCTestExpectation+.swift create mode 100644 Projects/App/UITests/Core/Protocols/UITestProtocols.swift create mode 100644 Projects/App/UITests/Identifiers/TestIdentifier.swift create mode 100644 Projects/App/UITests/TestPage/Scenario/TestUIScenarioAddDoriNotificationSettings.swift create mode 100644 Projects/App/UITests/TestPage/UIBase/UIBaseAddDoriView.swift create mode 100644 Projects/Feature/MyPage/Sources/FCMPushTestFeature.swift create mode 100644 Projects/Feature/MyPage/Sources/FCMPushTestView.swift diff --git a/Projects/App/UITests/Core/Base/TestUIBase.swift b/Projects/App/UITests/Core/Base/TestUIBase.swift new file mode 100644 index 0000000..f224725 --- /dev/null +++ b/Projects/App/UITests/Core/Base/TestUIBase.swift @@ -0,0 +1,30 @@ +// +// TestUIBase.swift +// DoriAppUITests +// +// Created by Codex on 4/27/26. +// + +import XCTest + +@MainActor +class TestUIBase: XCTestCase { + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + app.terminate() + } + + func waitForExistence( + _ element: XCUIElement, + timeout: TimeInterval = 5, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue(element.waitForExistence(timeout: timeout), file: file, line: line) + } +} diff --git a/Projects/App/UITests/Core/Extensions/XCTestExpectation+.swift b/Projects/App/UITests/Core/Extensions/XCTestExpectation+.swift new file mode 100644 index 0000000..de7596f --- /dev/null +++ b/Projects/App/UITests/Core/Extensions/XCTestExpectation+.swift @@ -0,0 +1,18 @@ +// +// XCTestExpectation+.swift +// DoriAppUITests +// +// Created by Codex on 4/27/26. +// + +import XCTest + +extension XCTestCase { + func waitBriefly(seconds: TimeInterval = 0.3) { + let expectation = expectation(description: "brief wait") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + expectation.fulfill() + } + wait(for: [expectation], timeout: seconds + 1) + } +} diff --git a/Projects/App/UITests/Core/Protocols/UITestProtocols.swift b/Projects/App/UITests/Core/Protocols/UITestProtocols.swift new file mode 100644 index 0000000..01299aa --- /dev/null +++ b/Projects/App/UITests/Core/Protocols/UITestProtocols.swift @@ -0,0 +1,13 @@ +// +// UITestProtocols.swift +// DoriAppUITests +// +// Created by Codex on 4/27/26. +// + +import XCTest + +@MainActor +protocol UITestPage { + var app: XCUIApplication { get } +} diff --git a/Projects/App/UITests/Identifiers/TestIdentifier.swift b/Projects/App/UITests/Identifiers/TestIdentifier.swift new file mode 100644 index 0000000..1482671 --- /dev/null +++ b/Projects/App/UITests/Identifiers/TestIdentifier.swift @@ -0,0 +1,27 @@ +// +// TestIdentifier.swift +// DoriAppUITests +// +// Created by Codex on 4/27/26. +// + +enum TestIdentifier { + enum LaunchEnvironment { + static let debugRoute = "DORI_DEBUG_ROUTE" + static let notificationEnabled = "DORI_DEBUG_NOTIFICATION_ENABLED" + static let partnerName = "DORI_DEBUG_PARTNER_NAME" + static let amount = "DORI_DEBUG_AMOUNT" + } + + enum DebugRoute { + static let addDoriPage3 = "addDoriPage3" + } + + enum AddDori { + static let submitButton = "완료" + static let notificationAlertTitle = "알림을 켜주세요" + static let notificationAlertDescription = "도리 일정을 놓치지 않도록\n시스템 알림 설정을 켜주세요" + static let openSettingsButton = "설정하기" + static let laterButton = "나중에" + } +} diff --git a/Projects/App/UITests/TestPage/Scenario/TestUIScenarioAddDoriNotificationSettings.swift b/Projects/App/UITests/TestPage/Scenario/TestUIScenarioAddDoriNotificationSettings.swift new file mode 100644 index 0000000..7c7cf42 --- /dev/null +++ b/Projects/App/UITests/TestPage/Scenario/TestUIScenarioAddDoriNotificationSettings.swift @@ -0,0 +1,33 @@ +// +// TestUIScenarioAddDoriNotificationSettings.swift +// DoriAppUITests +// +// Created by Codex on 4/27/26. +// + +import XCTest + +final class TestUIScenarioAddDoriNotificationSettings: TestUIBase { + func testShowNotificationSettingsAlertWhenSystemNotificationIsOffAfterAddingDori() { + app.launchEnvironment[TestIdentifier.LaunchEnvironment.debugRoute] = TestIdentifier.DebugRoute.addDoriPage3 + app.launchEnvironment[TestIdentifier.LaunchEnvironment.notificationEnabled] = "false" + app.launchEnvironment[TestIdentifier.LaunchEnvironment.partnerName] = "김철수" + app.launchEnvironment[TestIdentifier.LaunchEnvironment.amount] = "100000" + app.launch() + + let addDoriView = UIBaseAddDoriView(app: app) + + waitForExistence(addDoriView.submitButton) + addDoriView.submit() + + waitForExistence(addDoriView.notificationAlertTitle) + waitForExistence(addDoriView.notificationAlertDescription) + waitForExistence(addDoriView.openSettingsButton) + waitForExistence(addDoriView.laterButton) + + addDoriView.laterButton.tap() + waitBriefly() + + XCTAssertFalse(addDoriView.notificationAlertTitle.exists) + } +} diff --git a/Projects/App/UITests/TestPage/UIBase/UIBaseAddDoriView.swift b/Projects/App/UITests/TestPage/UIBase/UIBaseAddDoriView.swift new file mode 100644 index 0000000..a30c201 --- /dev/null +++ b/Projects/App/UITests/TestPage/UIBase/UIBaseAddDoriView.swift @@ -0,0 +1,41 @@ +// +// UIBaseAddDoriView.swift +// DoriAppUITests +// +// Created by Codex on 4/27/26. +// + +import XCTest + +@MainActor +final class UIBaseAddDoriView: UITestPage { + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + var submitButton: XCUIElement { + app.buttons[TestIdentifier.AddDori.submitButton] + } + + var notificationAlertTitle: XCUIElement { + app.staticTexts[TestIdentifier.AddDori.notificationAlertTitle] + } + + var notificationAlertDescription: XCUIElement { + app.staticTexts[TestIdentifier.AddDori.notificationAlertDescription] + } + + var openSettingsButton: XCUIElement { + app.buttons[TestIdentifier.AddDori.openSettingsButton] + } + + var laterButton: XCUIElement { + app.buttons[TestIdentifier.AddDori.laterButton] + } + + func submit() { + submitButton.tap() + } +} diff --git a/Projects/Feature/MyPage/Sources/FCMPushTestFeature.swift b/Projects/Feature/MyPage/Sources/FCMPushTestFeature.swift new file mode 100644 index 0000000..d747165 --- /dev/null +++ b/Projects/Feature/MyPage/Sources/FCMPushTestFeature.swift @@ -0,0 +1,234 @@ +// +// FCMPushTestFeature.swift +// Dori-iOS +// +// Created by 강동영 on 3/27/26. +// + +import ComposableArchitecture +import DoriDesignSystem +import DoriNetwork +import Foundation + +@Reducer +public struct FCMPushTestFeature { + @Dependency(\.fcmPushTestAPIClient) var fcmPushTestAPIClient + @Dependency(\.continuousClock) var clock + + public init() {} + + private enum CancelID { + case toastDismiss + } + + @ObservableState + public struct State: Equatable, Sendable { + public var userId: Int64? + public var title: String + public var body: String + public var isLoading: Bool + public var toastItem: DoriToast? + + public var userIdDisplayText: String { + userId.map { String($0) } ?? "" + } + + public init( + userId: Int64? = nil, + title: String = "도리 푸시 Test", + body: String = "테스트입니다.", + isLoading: Bool = false, + toastItem: DoriToast? = nil + ) { + self.userId = userId + self.title = title + self.body = body + self.isLoading = isLoading + self.toastItem = toastItem + } + } + + public enum Action: Equatable, Sendable { + case onAppear + case fetchUserIdResponse(Result) + case titleChanged(String) + case bodyChanged(String) + case sendButtonTapped + case sendResponse(Result) + case toastDismissed + case backButtonTapped + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case didTapBack + } + } + + public func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .onAppear: + state.isLoading = true + let client = fcmPushTestAPIClient + return .run { send in + do { + let userId = try await client.fetchUserId() + await send(.fetchUserIdResponse(.success(userId))) + } catch { + await send(.fetchUserIdResponse(.failure(RequestError.from(error: error)))) + } + } + + case .fetchUserIdResponse(.success(let userId)): + state.isLoading = false + state.userId = userId + return .none + + case .fetchUserIdResponse(.failure(let error)): + state.isLoading = false + return showToast(&state, type: .error, message: error.message) + + case .titleChanged(let title): + state.title = title + return .none + + case .bodyChanged(let body): + state.body = body + return .none + + case .sendButtonTapped: + guard let userId = state.userId, !state.isLoading else { return .none } + state.isLoading = true + let client = fcmPushTestAPIClient + let title = state.title + let body = state.body + return .run { send in + do { + try await client.sendPushTest(userId, title, body) + await send(.sendResponse(.success(true))) + } catch { + await send(.sendResponse(.failure(RequestError.from(error: error)))) + } + } + + case .sendResponse(.success): + state.isLoading = false + return showToast(&state, type: .info, message: "푸시 전송 성공!") + + case .sendResponse(.failure(let error)): + state.isLoading = false + return showToast(&state, type: .error, message: error.message) + + case .toastDismissed: + state.toastItem = nil + return .cancel(id: CancelID.toastDismiss) + + case .backButtonTapped: + return .send(.delegate(.didTapBack)) + + case .delegate: + return .none + } + } + + private func showToast( + _ state: inout State, + type: ToastType, + message: String + ) -> Effect { + let toast = DoriToast(type: type, message: message) + state.toastItem = toast + + let clock = self.clock + return .run { send in + try await clock.sleep(for: .seconds(toast.duration)) + await send(.toastDismissed) + } + .cancellable(id: CancelID.toastDismiss, cancelInFlight: true) + } +} + +@DependencyClient +public struct FCMPushTestAPIClient: Sendable { + public var fetchUserId: @Sendable () async throws -> Int64 + public var sendPushTest: @Sendable (Int64, String, String) async throws -> Void +} + +private enum FCMPushTestAPIClientError: LocalizedError { + case unconfigured + case invalidResponse + case backendError(String) + case noUserIdFound + + var errorDescription: String? { + switch self { + case .unconfigured: + return "FCMPushTestAPIClient가 구성되지 않았습니다." + case .invalidResponse: + return "서버 응답이 올바르지 않습니다." + case .backendError(let message): + return message + case .noUserIdFound: + return "userId를 찾을 수 없습니다." + } + } +} + +extension FCMPushTestAPIClient: DependencyKey { + public static let liveValue = Self( + fetchUserId: { throw FCMPushTestAPIClientError.unconfigured }, + sendPushTest: { _, _, _ in throw FCMPushTestAPIClientError.unconfigured } + ) +} + +extension FCMPushTestAPIClient: TestDependencyKey { + public static let previewValue = Self( + fetchUserId: { 12345 }, + sendPushTest: { _, _, _ in } + ) + + public static let testValue = Self() +} + +public extension FCMPushTestAPIClient { + static func live(networkService: any NetworkService) -> Self { + Self( + fetchUserId: { + let endpoint = FetchPartnersEndpoint(page: 0, size: 20) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse<[PartnerSummaryResponse]>.self + ) + if let apiError = response.error { + throw FCMPushTestAPIClientError.backendError(apiError.message ?? apiError.code) + } + guard response.success, let data = response.data else { + throw FCMPushTestAPIClientError.invalidResponse + } + guard let userId = data.first?.recentDoriList.first?.userId else { + throw FCMPushTestAPIClientError.noUserIdFound + } + return userId + }, + sendPushTest: { userId, title, body in + let endpoint = FCMPushTestEndpoint(userId: userId, title: title, body: body) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + if let apiError = response.error { + throw FCMPushTestAPIClientError.backendError(apiError.message ?? "푸시 전송에 실패했습니다.") + } + guard response.success else { + throw FCMPushTestAPIClientError.invalidResponse + } + } + ) + } +} + +public extension DependencyValues { + var fcmPushTestAPIClient: FCMPushTestAPIClient { + get { self[FCMPushTestAPIClient.self] } + set { self[FCMPushTestAPIClient.self] = newValue } + } +} diff --git a/Projects/Feature/MyPage/Sources/FCMPushTestView.swift b/Projects/Feature/MyPage/Sources/FCMPushTestView.swift new file mode 100644 index 0000000..909e8ee --- /dev/null +++ b/Projects/Feature/MyPage/Sources/FCMPushTestView.swift @@ -0,0 +1,117 @@ +// +// FCMPushTestView.swift +// Dori-iOS +// +// Created by 강동영 on 3/27/26. +// + +import ComposableArchitecture +import DoriDesignSystem +import SwiftUI + +public struct FCMPushTestView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack { + UIAsset.Colors.doriWhite.color + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + userIdField + titleField + bodyField + sendButton + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 24) + } + + if store.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.2)) + } + } + .doriNavigationBar( + DoriNavigationBarConfig.backWithTitle("FCM 푸시 테스트") { + store.send(.backButtonTapped) + } + ) + .onAppear { + store.send(.onAppear) + } + .doriToast(store.toastItem, alignment: .bottom) { + store.send(.toastDismissed) + } + } + + private var userIdField: some View { + VStack(alignment: .leading, spacing: 8) { + Text("userID") + .pretendard(.subtitle(.m2)) + .foregroundStyle(.grey600) + + HStack { + Text(store.userId == nil ? "불러오는 중..." : store.userIdDisplayText) + .pretendard(.body(.r3)) + .foregroundStyle(store.userId == nil ? .grey400 : .doriBlack) + Spacer() + } + .frame(height: 46) + .padding(.horizontal, 16) + .background(RoundedRectangle(cornerRadius: 10).fill(.doriWhite)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(.grey300, lineWidth: 1)) + } + } + + private var titleField: some View { + VStack(alignment: .leading, spacing: 8) { + Text("title") + .pretendard(.subtitle(.m2)) + .foregroundStyle(.grey600) + + DoriTextField( + "제목을 입력하세요", + memo: $store.title.sending(\.titleChanged) + ) + } + } + + private var bodyField: some View { + VStack(alignment: .leading, spacing: 8) { + Text("body") + .pretendard(.subtitle(.m2)) + .foregroundStyle(.grey600) + + DoriExpandingTextView( + "내용을 입력하세요", + text: $store.body.sending(\.bodyChanged) + ) + } + } + + private var sendButton: some View { + PrimaryButton(title: "전송") { + store.send(.sendButtonTapped) + } + .isEnable(store.userId != nil && !store.isLoading) + .padding(.top, 8) + } +} + +#Preview { + FCMPushTestView( + store: Store(initialState: FCMPushTestFeature.State()) { + FCMPushTestFeature() + } withDependencies: { + $0.fcmPushTestAPIClient = .previewValue + } + ) +} From d61b92ce4276020ae3fcc523bf6bca4f492026f0 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 28 Apr 2026 15:33:19 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20#42=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20API=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8/=EC=9D=91=EB=8B=B5=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알림 설정 조회/변경에 필요한 Endpoint 및 Response 모델을 정의한다. Co-Authored-By: Claude Opus 4.7 --- .../Endpoints/NotificationEndpoints.swift | 42 +++++++++++++++++++ .../Responses/NotificationResponses.swift | 27 ++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 Projects/Infra/DoriNetwork/Sources/Endpoints/NotificationEndpoints.swift create mode 100644 Projects/Infra/DoriNetwork/Sources/Responses/NotificationResponses.swift diff --git a/Projects/Infra/DoriNetwork/Sources/Endpoints/NotificationEndpoints.swift b/Projects/Infra/DoriNetwork/Sources/Endpoints/NotificationEndpoints.swift new file mode 100644 index 0000000..fd1174e --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Endpoints/NotificationEndpoints.swift @@ -0,0 +1,42 @@ +// +// NotificationEndpoints.swift +// Dori-iOS +// +// Created by 강동영 on 4/27/26. +// + +import Foundation + +public struct FetchNotificationSettingsEndpoint: Endpoint { + public let baseURL: String = NetworkConfig.baseURL + public let path: String = "/notifications/settings" + public let method: HTTPMethod = .GET + public let headers: [String: String] = [:] + public let queryParameters: [String: String] = [:] + public let body: Data? = nil + + public init() {} +} + +public struct UpdateNotificationSettingEndpoint: Endpoint { + public let baseURL: String = NetworkConfig.baseURL + public let path: String = "/notifications/settings" + public let method: HTTPMethod = .PUT + public let headers: [String: String] = [:] + public let queryParameters: [String: String] = [:] + public let body: Data? + + public init(typeCode: String, enabled: Bool) { + self.body = try? JSONEncoder().encode( + UpdateNotificationSettingRequest( + typeCode: typeCode, + enabled: enabled + ) + ) + } +} + +private struct UpdateNotificationSettingRequest: Encodable { + let typeCode: String + let enabled: Bool +} diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/NotificationResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/NotificationResponses.swift new file mode 100644 index 0000000..e9dc88c --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Responses/NotificationResponses.swift @@ -0,0 +1,27 @@ +// +// NotificationResponses.swift +// Dori-iOS +// +// Created by 강동영 on 4/27/26. +// + +import Foundation + +public struct NotificationSettingResponse: Decodable, Equatable, Sendable { + public let typeCode: String + public let category: String + public let displayName: String + public let enabled: Bool + + public init( + typeCode: String, + category: String, + displayName: String, + enabled: Bool + ) { + self.typeCode = typeCode + self.category = category + self.displayName = displayName + self.enabled = enabled + } +} From c6ef65e861fe2ef9908ad443e90e3975975e2b10 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 28 Apr 2026 15:33:25 +0900 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20#42=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EA=B8=B0=EA=B8=B0=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=B0=EB=84=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 설정 조회/토글 액션을 서버 API와 연결하고 실패 시 토글을 롤백한다. - 시스템 알림 권한이 꺼져 있을 때 안내 배너를 노출하고, 탭 시 시스템 설정으로 진입한다. - scenePhase 활성화 시 권한 상태를 재조회해 자동 갱신한다. Co-Authored-By: Claude Opus 4.7 --- .../push_disable_bell.imageset/Contents.json | 21 ++ .../push_disable_bell.png | Bin 0 -> 1439 bytes .../MyPage/Sources/DoriToggleSwitch.swift | 6 +- .../Sources/NotificationSettingsFeature.swift | 240 +++++++++++++++++- .../Sources/NotificationSettingsView.swift | 55 ++++ 5 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/push_disable_bell.imageset/Contents.json create mode 100644 Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/push_disable_bell.imageset/push_disable_bell.png diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/push_disable_bell.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/push_disable_bell.imageset/Contents.json new file mode 100644 index 0000000..04cd2b3 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/push_disable_bell.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "push_disable_bell.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/push_disable_bell.imageset/push_disable_bell.png b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/push_disable_bell.imageset/push_disable_bell.png new file mode 100644 index 0000000000000000000000000000000000000000..3f91545f76284e79bf4e6039616d3e094355a9a0 GIT binary patch literal 1439 zcmV;Q1z`G#P)A;g6n~F889^rXFQ~?DQBc$!(x6h>z4q44 z%GTNjq6~FD?4U9d#UCB40Fl})(o|%!Gj?bt11%7sL*=oXI(Ac*z?DvXeLU+UWhQ3R(ezJVcM$CW+1Z51S2`5s?JeHlF;&Nsa)^_5IE5$#R}}4iOFk zC=0QHT_V_?g-9x_@SFQPSwXbx3va}y-uju7oNrpZ!ie;Wzh08S zmq>8T`M(kIl#8=k)eP5NTKw!*hWq^mE&!7qXmGOP<|V-0ANRMaT6u|+1OD|8=o!m+ zHB8*pqF6(_L_&N8uy*fB%O~3@QLODeY`R&cBPxwExFV90!!l@# z!|2rZx2{7Bm??3ooEa=wEzm@TEWBQSy4islvA*%R%=-1cHlu27Z|kmUdt)=|tQg6b zw(UJ8QMe2eY|RSKcOSJ)+6pklPrDDgEJivqXv1NNiNTC`zW+!OICAqO#S7E6#srj| zKW{22JCL%K1c`qkMkz&j0reS%Kb)qrVlod!$Vh*FA6qYjumtBM4DcN zz=(j7f87dMXjR$1>xz~eymgd(CfLGico~A6$)Xq$u%zu-ALXQ~tPk~F$9wuntlg^^ z%kDHdddA*)l+Dm871?JTz)T>|)8kNNqiUNGAn>-5 zWwWanDGTB!0j1TAijn+I1NYVSx3<+5E);TT3uU7nY5*b$pn%W4ZyMYAIEWi^ zpd5!Qd+|_MD`1ziX2Kn-T7#Tn4ak|F9@a~ht(gpy$mm4 znBs>LVDY)*^@DH>3BV-ea1>dq!-`)F>MvY`^s5vcpIK*xQOH5?YCLrCtzRGk-x-*_ z>d2ZT0+Wy<-M1T@G?5Sc>I4|nSKzgZ??z5t6%r*PLONJMv*5ef!fslKf~m7~S_Bnn zSBCEx@kj)Ukh6v8KYhI$#JlAPy_r*SQ2)x=V?|g!76oOkoB{dtb|gri1lCnCK0C6r z(Xoppo1;!L-1d%E)##oykr$fvjmja1zDs8l#H0G&=5kP7-$gNEqqets+bf$C+llgN z2z?(g#p2DvzdZAzGbX@C)7}x3NPN8b*h5)?rT%T-u0^33NjdD+XN6gbbsPGqd~CEa@}W#mbh zI52ymQqU&PXa-GQ!HJt_bj`?O1R+TLmC{)~6e|<2FD{h0Y}d3Go-mj^E+$UE9oT-G t%7c0wWUGT9->~AglYU~IV~#lh{s*w^T?^HyZUX=S002ovPDHLkV1gd`oJarw literal 0 HcmV?d00001 diff --git a/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift b/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift index 2257b2f..83bc3d0 100644 --- a/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift +++ b/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift @@ -11,9 +11,9 @@ import SwiftUI struct DoriToggleSwitch: View { @Binding var isOn: Bool - private let width: CGFloat = 26 - private let height: CGFloat = 15 - private let thumbSize: CGFloat = 13 + private let width: CGFloat = 51 + private let height: CGFloat = 31 + private let thumbSize: CGFloat = 23 var body: some View { ZStack { diff --git a/Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift b/Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift index d41df8f..6717a28 100644 --- a/Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift +++ b/Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift @@ -6,44 +6,74 @@ // import ComposableArchitecture +import DoriNetwork +import Foundation +import UserNotifications @Reducer public struct NotificationSettingsFeature { + @Dependency(\.notificationSettingsAPIClient) var notificationSettingsAPIClient + public init() {} + // 서버 typeCode 정의 (Swagger /notifications/settings) + public enum NotificationTypeCode: String, Sendable { + case doriAlert = "DORI_ALERT" + case recordRemind = "RECORD_REMIND" + case monthlySummary = "MONTHLY_SUMMARY" + case relationBalance = "RELATION_BALANCE" + case activitySummary = "ACTIVITY_SUMMARY" + } + @ObservableState public struct State: Equatable, Sendable { + public var isSystemNotificationEnabled: Bool public var isAllPushEnabled: Bool public var isDoriAlertEnabled: Bool public var isRecordReminderEnabled: Bool public var isMonthlyEnabled: Bool public var isRelationBalanceEnabled: Bool public var isActivitySummaryEnabled: Bool + public var isLoading: Bool + public var errorMessage: String? public init( + isSystemNotificationEnabled: Bool = true, isAllPushEnabled: Bool = false, isDoriAlertEnabled: Bool = false, isRecordReminderEnabled: Bool = false, isMonthlyEnabled: Bool = false, isRelationBalanceEnabled: Bool = false, - isActivitySummaryEnabled: Bool = false + isActivitySummaryEnabled: Bool = false, + isLoading: Bool = false, + errorMessage: String? = nil ) { + self.isSystemNotificationEnabled = isSystemNotificationEnabled self.isAllPushEnabled = isAllPushEnabled self.isDoriAlertEnabled = isDoriAlertEnabled self.isRecordReminderEnabled = isRecordReminderEnabled self.isMonthlyEnabled = isMonthlyEnabled self.isRelationBalanceEnabled = isRelationBalanceEnabled self.isActivitySummaryEnabled = isActivitySummaryEnabled + self.isLoading = isLoading + self.errorMessage = errorMessage } } public enum Action: Equatable, Sendable { + case onAppear + case scenePhaseBecameActive + case systemNotificationStatusUpdated(Bool) + case openSystemSettingsTapped + case settingsLoaded([NotificationSettingResponse]) + case settingsLoadFailed(String) case allPushToggled(Bool) case doriAlertToggled(Bool) case recordReminderToggled(Bool) case monthlyToggled(Bool) case relationBalanceToggled(Bool) case activitySummaryToggled(Bool) + case updateFailed(typeCode: String, previousValue: Bool, message: String) case backButtonTapped case delegate(Delegate) @@ -54,28 +84,85 @@ public struct NotificationSettingsFeature { public func reduce(into state: inout State, action: Action) -> Effect { switch action { + case .onAppear: + state.isLoading = true + state.errorMessage = nil + let client = notificationSettingsAPIClient + return .merge( + .run { send in + do { + let settings = try await client.fetchSettings() + await send(.settingsLoaded(settings)) + } catch { + await send(.settingsLoadFailed(error.localizedDescription)) + } + }, + .run { send in + let enabled = await Self.fetchSystemNotificationEnabled() + await send(.systemNotificationStatusUpdated(enabled)) + } + ) + + case .scenePhaseBecameActive: + return .run { send in + let enabled = await Self.fetchSystemNotificationEnabled() + await send(.systemNotificationStatusUpdated(enabled)) + } + + case .systemNotificationStatusUpdated(let enabled): + state.isSystemNotificationEnabled = enabled + return .none + + case .openSystemSettingsTapped: + return .none + + case .settingsLoaded(let settings): + state.isLoading = false + for setting in settings { + applySetting(typeCode: setting.typeCode, enabled: setting.enabled, state: &state) + } + return .none + + case .settingsLoadFailed(let message): + state.isLoading = false + state.errorMessage = message + return .none + case .allPushToggled(let value): + // TODO: OS 시스템 알림 설정과 연동 (UNUserNotificationCenter / 시스템 설정 화면 이동) state.isAllPushEnabled = value return .none case .doriAlertToggled(let value): + let previous = state.isDoriAlertEnabled state.isDoriAlertEnabled = value - return .none + return updateSetting(.doriAlert, enabled: value, previousValue: previous) case .recordReminderToggled(let value): + let previous = state.isRecordReminderEnabled state.isRecordReminderEnabled = value - return .none + return updateSetting(.recordRemind, enabled: value, previousValue: previous) case .monthlyToggled(let value): + let previous = state.isMonthlyEnabled state.isMonthlyEnabled = value - return .none + return updateSetting(.monthlySummary, enabled: value, previousValue: previous) case .relationBalanceToggled(let value): + let previous = state.isRelationBalanceEnabled state.isRelationBalanceEnabled = value - return .none + return updateSetting(.relationBalance, enabled: value, previousValue: previous) case .activitySummaryToggled(let value): + let previous = state.isActivitySummaryEnabled state.isActivitySummaryEnabled = value + return updateSetting(.activitySummary, enabled: value, previousValue: previous) + + case .updateFailed(let typeCode, let previousValue, let message): + // 실패 시 토글 롤백 + applySetting(typeCode: typeCode, enabled: previousValue, state: &state) + state.errorMessage = message + // TODO: Toast 노출 등 사용자 피드백 처리 return .none case .backButtonTapped: @@ -85,4 +172,147 @@ public struct NotificationSettingsFeature { return .none } } + + private static func fetchSystemNotificationEnabled() async -> Bool { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined, .denied: + return false + @unknown default: + return false + } + } + + private func updateSetting( + _ type: NotificationTypeCode, + enabled: Bool, + previousValue: Bool + ) -> Effect { + let client = notificationSettingsAPIClient + let typeCode = type.rawValue + return .run { send in + do { + try await client.updateSetting(typeCode, enabled) + } catch { + await send( + .updateFailed( + typeCode: typeCode, + previousValue: previousValue, + message: error.localizedDescription + ) + ) + } + } + } + + private func applySetting( + typeCode: String, + enabled: Bool, + state: inout State + ) { + guard let type = NotificationTypeCode(rawValue: typeCode) else { return } + switch type { + case .doriAlert: + state.isDoriAlertEnabled = enabled + case .recordRemind: + state.isRecordReminderEnabled = enabled + case .monthlySummary: + state.isMonthlyEnabled = enabled + case .relationBalance: + state.isRelationBalanceEnabled = enabled + case .activitySummary: + state.isActivitySummaryEnabled = enabled + } + } +} + +// MARK: - API Client + +@DependencyClient +public struct NotificationSettingsAPIClient: Sendable { + public var fetchSettings: @Sendable () async throws -> [NotificationSettingResponse] + public var updateSetting: @Sendable (_ typeCode: String, _ enabled: Bool) async throws -> Void +} + +private enum NotificationSettingsAPIClientError: LocalizedError { + case unconfigured + case invalidResponse + case backendError(String) + + var errorDescription: String? { + switch self { + case .unconfigured: + return "NotificationSettingsAPIClient가 구성되지 않았습니다." + case .invalidResponse: + return "서버 응답이 올바르지 않습니다." + case .backendError(let message): + return message + } + } +} + +extension NotificationSettingsAPIClient: DependencyKey { + public static let liveValue = Self( + fetchSettings: { throw NotificationSettingsAPIClientError.unconfigured }, + updateSetting: { _, _ in throw NotificationSettingsAPIClientError.unconfigured } + ) +} + +extension NotificationSettingsAPIClient: TestDependencyKey { + public static let previewValue = Self( + fetchSettings: { [] }, + updateSetting: { _, _ in } + ) + + public static let testValue = Self() +} + +public extension NotificationSettingsAPIClient { + static func live(networkService: any NetworkService) -> Self { + Self( + fetchSettings: { + let endpoint = FetchNotificationSettingsEndpoint() + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse<[NotificationSettingResponse]>.self + ) + if let apiError = response.error { + throw NotificationSettingsAPIClientError.backendError( + apiError.message ?? "알림 설정 조회에 실패했습니다." + ) + } + guard response.success, let data = response.data else { + throw NotificationSettingsAPIClientError.invalidResponse + } + return data + }, + updateSetting: { typeCode, enabled in + let endpoint = UpdateNotificationSettingEndpoint( + typeCode: typeCode, + enabled: enabled + ) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + if let apiError = response.error { + throw NotificationSettingsAPIClientError.backendError( + apiError.message ?? "알림 설정 변경에 실패했습니다." + ) + } + guard response.success else { + throw NotificationSettingsAPIClientError.invalidResponse + } + } + ) + } +} + +public extension DependencyValues { + var notificationSettingsAPIClient: NotificationSettingsAPIClient { + get { self[NotificationSettingsAPIClient.self] } + set { self[NotificationSettingsAPIClient.self] = newValue } + } } diff --git a/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift b/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift index e688264..f8e7e89 100644 --- a/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift +++ b/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift @@ -11,6 +11,7 @@ import SwiftUI struct NotificationSettingsView: View { @Bindable var store: StoreOf + @Environment(\.scenePhase) private var scenePhase var body: some View { ZStack { @@ -19,6 +20,10 @@ struct NotificationSettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { + if !store.isSystemNotificationEnabled { + systemNotificationDisabledBanner + } + // 전체 푸시 수신 (Type 1: HStack { VStack { title, description }, switch }) notificationRowWithDescription( title: "앱 알림 받기", @@ -94,6 +99,52 @@ struct NotificationSettingsView: View { store.send(.backButtonTapped) } ) + .onAppear { store.send(.onAppear) } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + store.send(.scenePhaseBecameActive) + } + } + } + + // MARK: - 기기 알림 OFF 배너 + + @ViewBuilder + private var systemNotificationDisabledBanner: some View { + Button { + store.send(.openSystemSettingsTapped) + openSystemNotificationSettings() + } label: { + HStack(alignment: .top, spacing: 12) { + + Image(.pushDisableBell) + .font(.system(size: 20)) + .foregroundStyle(.grey600) + + Text("기기알림은 켜시면 새로운 소식을\n확인할 수 있습니다.") + .pretendard(.body(.r6)) + .foregroundStyle(.grey600) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 2) { + Text("설정") + .pretendard(.body(.m5)) + .foregroundStyle(Color.settingColor) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.settingColor) + } + } + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + + @MainActor + private func openSystemNotificationSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) } // MARK: - Type 1: HStack { VStack { title, description }, Spacer, switch } @@ -157,6 +208,10 @@ struct NotificationSettingsView: View { } } +private extension Color { + static let settingColor: Color = .init(red: 100/255, green: 130/255, blue: 173/255) +} + #Preview { NavigationStack { NotificationSettingsView( From f19adce6b575210145d64feda82d1178d59e8d1e Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 28 Apr 2026 15:33:30 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20#42=20=EB=8F=84=EB=A6=AC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=9B=84=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EA=B6=8C=ED=95=9C=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EC=95=8C=EB=9F=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도리 등록 성공 후 시스템 알림 권한이 꺼져 있으면 설정 유도 알럿을 노출하고, 탭 시 시스템 설정으로 진입한다. 알럿 타이틀 멀티라인 정렬을 위해 DoriCommonAlert의 title 정렬도 함께 조정했다. Co-Authored-By: Claude Opus 4.7 --- .../Sources/DoriCommonAlert.swift | 9 +-- .../AddDori/Sources/AddDoriFeature.swift | 59 ++++++++++++++++++- .../Feature/AddDori/Sources/AddDoriView.swift | 28 +++++++++ .../AddDori/Tests/AddDoriFeatureTests.swift | 45 ++++++++++++++ 4 files changed, 136 insertions(+), 5 deletions(-) diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriCommonAlert.swift b/Projects/Core/DoriDesignSystem/Sources/DoriCommonAlert.swift index a646390..234dd24 100644 --- a/Projects/Core/DoriDesignSystem/Sources/DoriCommonAlert.swift +++ b/Projects/Core/DoriDesignSystem/Sources/DoriCommonAlert.swift @@ -57,6 +57,7 @@ public struct DoriCommonAlert: View { Text(title) .pretendard(.headline(.h1)) .foregroundStyle(.doriBlack) + .multilineTextAlignment(.center) if let description = description { Text(description) @@ -102,11 +103,11 @@ public struct DoriCommonAlert: View { #Preview { DoriCommonAlert( isPresented: .constant(true), - title: "회원탈퇴", - description: "정말 도리를 탈퇴하실건가요?\n재가입 시에도 이용 내역은 복구되지 않습니다.", - secondaryButton: AlertButton(.no) { + title: "도리 알림을 켜면\n등록한 도리를 놓치지 않아요!", + description: nil, + secondaryButton: AlertButton(title: "나중에") { }, - primaryButton: AlertButton(.yes) { + primaryButton: AlertButton(title: "알림 켜기") { } ) } diff --git a/Projects/Feature/AddDori/Sources/AddDoriFeature.swift b/Projects/Feature/AddDori/Sources/AddDoriFeature.swift index 59ca074..a6eb17d 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriFeature.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriFeature.swift @@ -10,6 +10,7 @@ import ComposableArchitecture import DoriCore import DoriNetwork import DoriDesignSystem +import UserNotifications @Reducer public struct AddDoriFeature { @@ -46,6 +47,8 @@ public struct AddDoriFeature { public var memo: String = "" public var isSubmitting: Bool = false + public var isNotificationSettingsAlertPresented: Bool = false + public var pendingCreatedDori: Dori? // MARK: - Validation @@ -122,6 +125,8 @@ public struct AddDoriFeature { // Submit case submitTapped case submitResponse(Result) + case notificationAuthorizationStatusResponse(Bool, Dori) + case notificationSettingsAlertDismissed // Delegate case delegate(Delegate) @@ -150,6 +155,7 @@ public struct AddDoriFeature { @Dependency(\.continuousClock) var clock @Dependency(\.addDoriAPIClient) var apiClient + @Dependency(\.userNotificationSettingsClient) var userNotificationSettingsClient // MARK: - Reducer @@ -314,15 +320,66 @@ public struct AddDoriFeature { case let .submitResponse(.success(response)): state.isSubmitting = false - return .send(.delegate(.doriCreated(response))) + let isNotificationEnabled = userNotificationSettingsClient.isNotificationEnabled + return .run { send in + let isEnabled = await isNotificationEnabled() + await send(.notificationAuthorizationStatusResponse(isEnabled, response)) + } case .submitResponse(.failure): state.isSubmitting = false return .none + case let .notificationAuthorizationStatusResponse(isEnabled, response): + if isEnabled { + return .send(.delegate(.doriCreated(response))) + } + + state.pendingCreatedDori = response + state.isNotificationSettingsAlertPresented = true + return .none + + case .notificationSettingsAlertDismissed: + state.isNotificationSettingsAlertPresented = false + guard let createdDori = state.pendingCreatedDori else { return .none } + state.pendingCreatedDori = nil + return .send(.delegate(.doriCreated(createdDori))) + case .delegate: return .none } } } } + +@DependencyClient +public struct UserNotificationSettingsClient: Sendable { + public var isNotificationEnabled: @Sendable () async -> Bool = { true } +} + +extension UserNotificationSettingsClient: DependencyKey { + public static let liveValue = Self( + isNotificationEnabled: { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined, .denied: + return false + @unknown default: + return false + } + } + ) + + public static let testValue = Self( + isNotificationEnabled: { true } + ) +} + +public extension DependencyValues { + var userNotificationSettingsClient: UserNotificationSettingsClient { + get { self[UserNotificationSettingsClient.self] } + set { self[UserNotificationSettingsClient.self] = newValue } + } +} diff --git a/Projects/Feature/AddDori/Sources/AddDoriView.swift b/Projects/Feature/AddDori/Sources/AddDoriView.swift index 9a4f385..5df13bb 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriView.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriView.swift @@ -72,9 +72,37 @@ public struct AddDoriView: View { store.send(.datePickerToggled) } } + + if store.isNotificationSettingsAlertPresented { + DoriCommonAlert( + isPresented: Binding( + get: { store.isNotificationSettingsAlertPresented }, + set: { isPresented in + if !isPresented { + store.send(.notificationSettingsAlertDismissed) + } + } + ), + title: "도리 알림을 켜면\n등록한 도리를 놓치지 않아요!", + description: nil, + secondaryButton: AlertButton(title: "나중에") { + store.send(.notificationSettingsAlertDismissed) + }, + primaryButton: AlertButton(title: "알림 켜기") { + openNotificationSettings() + store.send(.notificationSettingsAlertDismissed) + } + ) + } } } + @MainActor + private func openNotificationSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + @ViewBuilder private var bottomCTA: some View { PrimaryButton(title: currentButtonTitle) { diff --git a/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift b/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift index 18d45c3..c24dd9e 100644 --- a/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift +++ b/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift @@ -789,6 +789,7 @@ struct AddDoriFeatureTests { $0.isSubmitting = false } + await store.receive(.notificationAuthorizationStatusResponse(true, mockResponse)) await store.receive(.delegate(.doriCreated(mockResponse))) } @@ -860,6 +861,7 @@ struct AddDoriFeatureTests { $0.isSubmitting = false } + await store.receive(.notificationAuthorizationStatusResponse(true, mockResponse)) await store.receive(.delegate(.doriCreated(mockResponse))) #expect(capturedRequest?.direction == "주도리") @@ -871,6 +873,49 @@ struct AddDoriFeatureTests { #expect(capturedRequest?.memo == "테스트 메모") } + @Test("create 모드 - 시스템 알림 OFF 시 알림 설정 팝업 노출 후 완료") + func createSubmitSuccessWithNotificationDisabled() async { + var initial = AddDoriFeature.State() + initial.searchQuery = "김철수" + initial.amountText = "100,000" + + let mockResponse = DoriResponsesDTO.mock( + partnerName: "김철수", + relationship: "친구", + eventType: "결혼식", + amount: 100_000 + ) + + let store = TestStore( + initialState: initial + ) { + AddDoriFeature() + } withDependencies: { + $0.addDoriAPIClient.createDori = { _ in mockResponse } + $0.userNotificationSettingsClient.isNotificationEnabled = { false } + } + + await store.send(.submitTapped) { + $0.isSubmitting = true + } + + await store.receive(.submitResponse(.success(mockResponse))) { + $0.isSubmitting = false + } + + await store.receive(.notificationAuthorizationStatusResponse(false, mockResponse)) { + $0.pendingCreatedDori = mockResponse + $0.isNotificationSettingsAlertPresented = true + } + + await store.send(.notificationSettingsAlertDismissed) { + $0.isNotificationSettingsAlertPresented = false + $0.pendingCreatedDori = nil + } + + await store.receive(.delegate(.doriCreated(mockResponse))) + } + @Test("isSubmitting = true 상태에서 submitTapped - no-op") func submitTappedWhileSubmittingIsNoOp() async { var initial = AddDoriFeature.State() From 692da2bbd6aa2e6d001dfd81771090b1feb5ce0f Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 28 Apr 2026 15:33:38 +0900 Subject: [PATCH 10/19] =?UTF-8?q?chore:=20#42=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20FCM=20=ED=91=B8=EC=8B=9C?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A7=84=EC=9E=85=EC=A0=90=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 FCMPushTestFeature를 마이페이지 네비게이션에 연결하고, 필요한 Core 의존성을 MyPage 모듈에 추가한다. Co-Authored-By: Claude Opus 4.7 --- .../MyPage/Sources/MyPageFeature.swift | 22 ++++++++++++- .../Feature/MyPage/Sources/MyPageView.swift | 31 +++++++++++++++++++ Projects/Feature/Project.swift | 1 + 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Projects/Feature/MyPage/Sources/MyPageFeature.swift b/Projects/Feature/MyPage/Sources/MyPageFeature.swift index 4dbeaeb..b7043c5 100644 --- a/Projects/Feature/MyPage/Sources/MyPageFeature.swift +++ b/Projects/Feature/MyPage/Sources/MyPageFeature.swift @@ -30,13 +30,15 @@ public struct MyPageFeature { public var isWithdrawAlertPresented: Bool public var toastItem: DoriToast? public var notificationSettings: NotificationSettingsFeature.State + public var fcmPushTest: FCMPushTestFeature.State public init( isLoading: Bool = false, isLogoutAlertPresented: Bool = false, isWithdrawAlertPresented: Bool = false, toastItem: DoriToast? = nil, - notificationSettings: NotificationSettingsFeature.State = NotificationSettingsFeature.State() + notificationSettings: NotificationSettingsFeature.State = NotificationSettingsFeature.State(), + fcmPushTest: FCMPushTestFeature.State = FCMPushTestFeature.State() ) { self.navigationPath = [] self.isLoading = isLoading @@ -44,6 +46,7 @@ public struct MyPageFeature { self.isWithdrawAlertPresented = isWithdrawAlertPresented self.toastItem = toastItem self.notificationSettings = notificationSettings + self.fcmPushTest = fcmPushTest } } @@ -51,8 +54,10 @@ public struct MyPageFeature { case onAppear case privacyPolicyTapped case notificationSettingsTapped + case fcmPushTestTapped case navigationPathChanged([Route]) case notificationSettings(NotificationSettingsFeature.Action) + case fcmPushTest(FCMPushTestFeature.Action) case logoutButtonTapped case withdrawButtonTapped @@ -80,6 +85,7 @@ public struct MyPageFeature { public enum Route: Hashable, Sendable { case privacyPolicy case notificationSettings + case fcmPushTest } public func reduce(into state: inout State, action: Action) -> Effect { @@ -95,6 +101,11 @@ public struct MyPageFeature { state.navigationPath.append(.notificationSettings) return .none + case .fcmPushTestTapped: + state.fcmPushTest = FCMPushTestFeature.State() + state.navigationPath.append(.fcmPushTest) + return .none + case .navigationPathChanged(let path): state.navigationPath = path return .none @@ -108,6 +119,15 @@ public struct MyPageFeature { .reduce(into: &state.notificationSettings, action: notifAction) .map(Action.notificationSettings) + case .fcmPushTest(.delegate(.didTapBack)): + state.navigationPath.removeAll(where: { $0 == .fcmPushTest }) + return .none + + case .fcmPushTest(let fcmAction): + return FCMPushTestFeature() + .reduce(into: &state.fcmPushTest, action: fcmAction) + .map(Action.fcmPushTest) + case .logoutButtonTapped: state.isLogoutAlertPresented = true return .none diff --git a/Projects/Feature/MyPage/Sources/MyPageView.swift b/Projects/Feature/MyPage/Sources/MyPageView.swift index 6dab2a2..3993b10 100644 --- a/Projects/Feature/MyPage/Sources/MyPageView.swift +++ b/Projects/Feature/MyPage/Sources/MyPageView.swift @@ -6,6 +6,7 @@ // import ComposableArchitecture +import DoriCore import DoriDesignSystem import SwiftUI @@ -32,6 +33,9 @@ public struct MyPageView: View { settingInfoView accountInfoView notificationInfoView + if BuildEnvironment.current.isTestingEnabled { + debugInfoView + } Spacer() } @@ -89,6 +93,13 @@ public struct MyPageView: View { action: \.notificationSettings ) ) + case .fcmPushTest: + FCMPushTestView( + store: store.scope( + state: \.fcmPushTest, + action: \.fcmPushTest + ) + ) } } } @@ -155,6 +166,26 @@ public struct MyPageView: View { } } + + private var debugInfoView: some View { + VStack(alignment: .leading, spacing: 16) { + Divider() + + HStack { + Text("디버깅 툴") + .pretendard(.subtitle(.m2)) + .foregroundStyle(.grey600) + Spacer() + Text(BuildEnvironment.current.displayName) + .pretendard(.body(.r3)) + .foregroundStyle(.grey400) + } + + NavigationRow("FCM 푸시 테스트") { + store.send(.fcmPushTestTapped) + } + } + } private var logoutAlertBinding: Binding { Binding( diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift index 7303f1d..0dc249c 100644 --- a/Projects/Feature/Project.swift +++ b/Projects/Feature/Project.swift @@ -62,6 +62,7 @@ let project = Project.dori( DoriModules.designSystem.module.projectDependency, DoriModules.network.module.projectDependency, DoriModules.keychain.module.projectDependency, + DoriModules.core.module.projectDependency, .external(.composableArchitecture) ] ), From 31a6f1d792d3205992c954f6c46496d7e8c68379 Mon Sep 17 00:00:00 2001 From: kangddong Date: Tue, 28 Apr 2026 15:33:44 +0900 Subject: [PATCH 11/19] =?UTF-8?q?chore:=20#42=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=20=EB=B9=8C=EB=93=9C=20=EC=95=B1=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=ED=95=9C=EA=B5=AD=EC=96=B4=EB=A1=9C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CFBundleDisplayName을 APP_DISPLAY_NAME 변수로 분리하여 Debug는 'Dori-Debug', Release는 '도리'로 표시되도록 한다. PRODUCT_NAME은 ASCII로 유지해 .app 번들 파일명을 보존한다. Co-Authored-By: Claude Opus 4.7 --- Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift | 1 + Tuist/ProjectDescriptionHelpers/Settings+Extension.swift | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index 7d8d539..e5b4a44 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -10,6 +10,7 @@ import ProjectDescription extension InfoPlist { static let commonDictionary: [String: Plist.Value] = [ "UILaunchScreen": .dictionary([:]), + "CFBundleDisplayName": "$(APP_DISPLAY_NAME)", "BASE_URL": "$(BASE_URL)", "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", "Appearance": "Light", diff --git a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift index 7eb04de..45ca18a 100644 --- a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift @@ -98,6 +98,7 @@ public extension Settings { let debugSettings: [String: SettingValue] = [ "PRODUCT_NAME": .string(BuildConfiguration.debug.appName), + "APP_DISPLAY_NAME": .string(BuildConfiguration.debug.appName), "ENABLE_TESTABILITY": "YES", "GCC_OPTIMIZATION_LEVEL": "0", "SWIFT_OPTIMIZATION_LEVEL": "-Onone", @@ -108,6 +109,7 @@ public extension Settings { let releaseSettings: [String: SettingValue] = [ "PRODUCT_NAME": .string(BuildConfiguration.release.appName), + "APP_DISPLAY_NAME": .string(Environment.App.displayName), "SWIFT_OPTIMIZATION_LEVEL": "-O", "ENABLE_TESTABILITY": "NO", "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", From 469c4e2e82fbaa1e661c544fcd4f7fcac9e31820 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 29 Apr 2026 11:35:21 +0900 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20#42=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=ED=86=A0=EA=B8=80=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=84=A4=EB=AA=85=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=8F=99=EC=A0=81=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../Feature/MyPage/Sources/NotificationSettingsView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift b/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift index f8e7e89..e22ea04 100644 --- a/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift +++ b/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift @@ -13,6 +13,10 @@ struct NotificationSettingsView: View { @Bindable var store: StoreOf @Environment(\.scenePhase) private var scenePhase + private var allPushDescrition: String { + store.isAllPushEnabled ? "앱 알림 받기" : "알림이 꺼져 있어요\n알림을 켜고 소식을 받아보세요" + } + var body: some View { ZStack { UIAsset.Colors.doriWhite.color @@ -27,7 +31,7 @@ struct NotificationSettingsView: View { // 전체 푸시 수신 (Type 1: HStack { VStack { title, description }, switch }) notificationRowWithDescription( title: "앱 알림 받기", - description: "알림이 꺼져 있어요\n알림을 받으려면 켜주세요", + description: allPushDescrition, isOn: $store.isAllPushEnabled.sending(\.allPushToggled) ) From fb862031510d542343f92cf628abdf1de095f057 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 29 Apr 2026 11:35:25 +0900 Subject: [PATCH 13/19] =?UTF-8?q?chore:=20#42=20=EC=95=B1=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=201.1.0=EC=9C=BC=EB=A1=9C=20=EC=98=AC=EB=A6=AC?= =?UTF-8?q?=EA=B3=A0=20Info.plist=EC=97=90=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- Tuist/ProjectDescriptionHelpers/Environment.swift | 2 +- Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Tuist/ProjectDescriptionHelpers/Environment.swift b/Tuist/ProjectDescriptionHelpers/Environment.swift index 0ebdd1f..e59cbc1 100644 --- a/Tuist/ProjectDescriptionHelpers/Environment.swift +++ b/Tuist/ProjectDescriptionHelpers/Environment.swift @@ -40,7 +40,7 @@ public struct Environment { public struct App { public static let baseBundleId = "\(organizationName).dori" public static let displayName = "도리" - public static let version = "1.0.0" + public static let version = "1.1.0" public static let buildNumber = "1" public static func bundleId(for configuration: BuildConfiguration = .release) -> String { diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index e5b4a44..f8203eb 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -11,6 +11,8 @@ extension InfoPlist { static let commonDictionary: [String: Plist.Value] = [ "UILaunchScreen": .dictionary([:]), "CFBundleDisplayName": "$(APP_DISPLAY_NAME)", + "CFBundleShortVersionString": "$(MARKETING_VERSION)", + "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)", "BASE_URL": "$(BASE_URL)", "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", "Appearance": "Light", From 3ecf5395ece801747518b31bb045b9373e7dc6c0 Mon Sep 17 00:00:00 2001 From: kangddong Date: Thu, 30 Apr 2026 00:09:04 +0900 Subject: [PATCH 14/19] =?UTF-8?q?chore:=20#42=20=ED=83=80=EA=B2=9F=20desti?= =?UTF-8?q?nation=EC=9D=84=20iPhone=EC=9C=BC=EB=A1=9C=20=ED=95=9C=EC=A0=95?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=84=B8=EB=A1=9C=20=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- Projects/App/Project.swift | 2 +- Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift | 3 +++ Tuist/ProjectDescriptionHelpers/Target+Extension.swift | 6 +++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 97b0dd3..5333e6c 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -33,7 +33,7 @@ let project = Project.dori( ), .target( name: "DoriAppUITests", - destinations: .iOS, + destinations: [.iPhone], product: .uiTests, bundleId: "\(Environment.App.baseBundleId).UITests", deploymentTargets: .iOS(Environment.deploymentTarget), diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index f8203eb..3ce33e8 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -30,6 +30,9 @@ extension InfoPlist { "kakaokompassauth", "kakaolink", ], + "UISupportedInterfaceOrientations": [ + "UIInterfaceOrientationPortrait", + ], ] public static func baseInfoPlist() -> InfoPlist { diff --git a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift index 786d04b..1dfc1c5 100644 --- a/Tuist/ProjectDescriptionHelpers/Target+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Target+Extension.swift @@ -16,7 +16,7 @@ public extension Target { ) -> Target { .target( name: module.name, - destinations: .iOS, + destinations: [.iPhone], product: .framework, bundleId: "\(Environment.App.baseBundleId).\(module.name)", deploymentTargets: .iOS(Environment.deploymentTarget), @@ -33,7 +33,7 @@ public extension Target { ) -> Target { .target( name: "\(module.name)Tests", - destinations: .iOS, + destinations: [.iPhone], product: .unitTests, bundleId: "\(Environment.App.baseBundleId).\(module.name)Tests", deploymentTargets: .iOS(Environment.deploymentTarget), @@ -55,7 +55,7 @@ public extension Target { ) -> Target { .target( name: name, - destinations: .iOS, + destinations: [.iPhone], product: .app, bundleId: bundleId, deploymentTargets: .iOS(Environment.deploymentTarget), From 353ea2ab19eb9a479b057ec7ca89c29e90b56855 Mon Sep 17 00:00:00 2001 From: kangddong Date: Thu, 30 Apr 2026 00:24:37 +0900 Subject: [PATCH 15/19] =?UTF-8?q?fix:=20#42=20Info.plist=20=EB=94=95?= =?UTF-8?q?=EC=85=94=EB=84=88=EB=A6=AC=20=EB=A6=AC=ED=84=B0=EB=9F=B4?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A8=EA=B3=84=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=ED=95=B4=20Swift=206=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=ED=82=A4=20=ED=8A=B8=EB=9E=A9=20=ED=9A=8C=ED=94=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI(Xcode 26.3 / Swift 6.2.4)에서 commonDictionary 리터럴 초기화 중 Dictionary literal contains duplicate keys 트랩이 발생하여, 빈 딕셔너리에 항목을 하나씩 할당하는 방식으로 우회한다. Co-Authored-By: Claude Opus 4.7 --- .../InfoPlist+Extension.swift | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index 3ce33e8..90ba4df 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -8,33 +8,35 @@ import ProjectDescription extension InfoPlist { - static let commonDictionary: [String: Plist.Value] = [ - "UILaunchScreen": .dictionary([:]), - "CFBundleDisplayName": "$(APP_DISPLAY_NAME)", - "CFBundleShortVersionString": "$(MARKETING_VERSION)", - "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)", - "BASE_URL": "$(BASE_URL)", - "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", - "Appearance": "Light", - "ITSAppUsesNonExemptEncryption": .boolean(false), - "FirebaseAppDelegateProxyEnabled": .boolean(false), - "FirebaseMessagingAutoInitEnabled": .boolean(true), - "CFBundleURLTypes": [ - [ + static var commonDictionary: [String: Plist.Value] { + var dict: [String: Plist.Value] = [:] + dict["UILaunchScreen"] = .dictionary([:]) + dict["CFBundleDisplayName"] = "$(APP_DISPLAY_NAME)" + dict["CFBundleShortVersionString"] = "$(MARKETING_VERSION)" + dict["CFBundleVersion"] = "$(CURRENT_PROJECT_VERSION)" + dict["BASE_URL"] = "$(BASE_URL)" + dict["KAKAO_NATIVE_APP_KEY"] = "$(KAKAO_NATIVE_APP_KEY)" + dict["Appearance"] = "Light" + dict["ITSAppUsesNonExemptEncryption"] = .boolean(false) + dict["FirebaseAppDelegateProxyEnabled"] = .boolean(false) + dict["FirebaseMessagingAutoInitEnabled"] = .boolean(true) + dict["CFBundleURLTypes"] = .array([ + .dictionary([ "CFBundleTypeRole": "Editor", - "CFBundleURLName": Plist.Value.string(Environment.App.baseBundleId), - "CFBundleURLSchemes": ["$(KAKAO_CAllBACK)"], - ], - ], - "LSApplicationQueriesSchemes": [ - "kakaokompassauth", - "kakaolink", - ], - "UISupportedInterfaceOrientations": [ - "UIInterfaceOrientationPortrait", - ], - ] - + "CFBundleURLName": .string(Environment.App.baseBundleId), + "CFBundleURLSchemes": .array([.string("$(KAKAO_CAllBACK)")]), + ]), + ]) + dict["LSApplicationQueriesSchemes"] = .array([ + .string("kakaokompassauth"), + .string("kakaolink"), + ]) + dict["UISupportedInterfaceOrientations"] = .array([ + .string("UIInterfaceOrientationPortrait"), + ]) + return dict + } + public static func baseInfoPlist() -> InfoPlist { return .extendingDefault(with: commonDictionary) } From b27b3df20b1cae7f3dfba3f2c43b4e00e4be5e73 Mon Sep 17 00:00:00 2001 From: kangddong Date: Thu, 30 Apr 2026 00:33:19 +0900 Subject: [PATCH 16/19] =?UTF-8?q?fix:=20#42=20develop=20=EB=A8=B8=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EA=B9=A8=EC=A7=84=20InfoPlist=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=B3=B5=EA=B5=AC=20=EB=B0=8F=20orientation=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=A8=B8=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c5c4a6a 머지 결과 파일이 구문상 깨져 CI가 실패했음. develop가 도입한 iPhone 세로 고정 의도를 유지하되, commonDictionary 리터럴은 fb86203 시점의 형태로 복원해 Swift 6 dictionary literal 트랩을 회피하고, orientation 키는 baseInfoPlist에서 mutation으로 추가한다. Co-Authored-By: Claude Opus 4.7 --- .../InfoPlist+Extension.swift | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index 639ffb6..2fa7bac 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -10,31 +10,33 @@ import ProjectDescription extension InfoPlist { static let commonDictionary: [String: Plist.Value] = [ "UILaunchScreen": .dictionary([:]), + "CFBundleDisplayName": "$(APP_DISPLAY_NAME)", + "CFBundleShortVersionString": "$(MARKETING_VERSION)", + "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)", "BASE_URL": "$(BASE_URL)", "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", "Appearance": "Light", - "UISupportedInterfaceOrientations": [ - "UIInterfaceOrientationPortrait" - ], "ITSAppUsesNonExemptEncryption": .boolean(false), + "FirebaseAppDelegateProxyEnabled": .boolean(false), + "FirebaseMessagingAutoInitEnabled": .boolean(true), "CFBundleURLTypes": [ [ "CFBundleTypeRole": "Editor", - "CFBundleURLName": .string(Environment.App.baseBundleId), - "CFBundleURLSchemes": .array([.string("$(KAKAO_CAllBACK)")]), - ]), - ]) - dict["LSApplicationQueriesSchemes"] = .array([ - .string("kakaokompassauth"), - .string("kakaolink"), - ]) + "CFBundleURLName": Plist.Value.string(Environment.App.baseBundleId), + "CFBundleURLSchemes": ["$(KAKAO_CAllBACK)"], + ], + ], + "LSApplicationQueriesSchemes": [ + "kakaokompassauth", + "kakaolink", + ], + ] + + public static func baseInfoPlist() -> InfoPlist { + var dict = commonDictionary dict["UISupportedInterfaceOrientations"] = .array([ .string("UIInterfaceOrientationPortrait"), ]) - return dict - } - - public static func baseInfoPlist() -> InfoPlist { - return .extendingDefault(with: commonDictionary) + return .extendingDefault(with: dict) } } From e1bb3ace7a18ac9ede7c0fe11519567ef3c407eb Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 6 May 2026 16:02:50 +0900 Subject: [PATCH 17/19] =?UTF-8?q?test:=20#42=20CI=20Xcode=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EA=B3=A0=EC=A0=95=20=EA=B0=80=EC=84=A4=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InfoPlist commonDictionary 리터럴에 UISupportedInterfaceOrientations 복원 (Xcode 26.3에서 dictionary literal 중복 키 트랩이 발생하던 형태) - build.yml Xcode 버전을 latest-stable → 26.2 로 명시 고정 Xcode 버전 차이가 원인이었는지 CI에서 확인하기 위한 임시 커밋이며, 검증 후 롤백/재정리 예정. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 2 +- .../ProjectDescriptionHelpers/InfoPlist+Extension.swift | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16bb972..0891d28 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest-stable + xcode-version: '26.2' - name: Setup mise and Tuist uses: jdx/mise-action@v2 diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index 2fa7bac..4a0bb1d 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -30,13 +30,12 @@ extension InfoPlist { "kakaokompassauth", "kakaolink", ], + "UISupportedInterfaceOrientations": [ + "UIInterfaceOrientationPortrait", + ], ] public static func baseInfoPlist() -> InfoPlist { - var dict = commonDictionary - dict["UISupportedInterfaceOrientations"] = .array([ - .string("UIInterfaceOrientationPortrait"), - ]) - return .extendingDefault(with: dict) + return .extendingDefault(with: commonDictionary) } } From 38091635fd0d9a1d58408682ddae4f3feb62c457 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 6 May 2026 16:14:58 +0900 Subject: [PATCH 18/19] =?UTF-8?q?revert:=20#42=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=9B=84=20Xcode=20latest-stable=20+=20mutation=20=EC=9A=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=BD=94=EB=93=9C=EB=A1=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가설 검증 결과 Xcode 26.2 고정 시 CI 통과 확인됨. 운영 정책상 Xcode latest-stable을 유지하되, dictionary literal 트랩을 회피하기 위한 baseInfoPlist mutation 방식 코드는 유지한다. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 2 +- .../ProjectDescriptionHelpers/InfoPlist+Extension.swift | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0891d28..16bb972 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '26.2' + xcode-version: latest-stable - name: Setup mise and Tuist uses: jdx/mise-action@v2 diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index 4a0bb1d..2fa7bac 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -30,12 +30,13 @@ extension InfoPlist { "kakaokompassauth", "kakaolink", ], - "UISupportedInterfaceOrientations": [ - "UIInterfaceOrientationPortrait", - ], ] public static func baseInfoPlist() -> InfoPlist { - return .extendingDefault(with: commonDictionary) + var dict = commonDictionary + dict["UISupportedInterfaceOrientations"] = .array([ + .string("UIInterfaceOrientationPortrait"), + ]) + return .extendingDefault(with: dict) } } From 5938e0dca672856e90559dde27e8ae8f9bb0d7a7 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 6 May 2026 16:22:39 +0900 Subject: [PATCH 19/19] =?UTF-8?q?test:=20#42=20CI=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=9E=AC=ED=98=84=20=E2=80=94=20literal=2013=ED=82=A4=20+=20Xc?= =?UTF-8?q?ode=20latest-stable=20=EC=A1=B0=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dictionary literal 트랩 가설 재검증을 위해 CI가 실패하던 시점의 InfoPlist 형태(commonDictionary literal에 UISupportedInterfaceOrientations 포함)로 되돌린다. build.yml은 latest-stable 유지. Co-Authored-By: Claude Opus 4.7 --- .../ProjectDescriptionHelpers/InfoPlist+Extension.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift index 2fa7bac..4a0bb1d 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -30,13 +30,12 @@ extension InfoPlist { "kakaokompassauth", "kakaolink", ], + "UISupportedInterfaceOrientations": [ + "UIInterfaceOrientationPortrait", + ], ] public static func baseInfoPlist() -> InfoPlist { - var dict = commonDictionary - dict["UISupportedInterfaceOrientations"] = .array([ - .string("UIInterfaceOrientationPortrait"), - ]) - return .extendingDefault(with: dict) + return .extendingDefault(with: commonDictionary) } }