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/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 6f83472..5333e6c 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, @@ -24,10 +24,24 @@ 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) ], + entitlements: .file(path: "Resources/DoriApp.entitlements") + ), + .target( + name: "DoriAppUITests", + destinations: [.iPhone], + 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/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift new file mode 100644 index 0000000..9772f85 --- /dev/null +++ b/Projects/App/Sources/AppDelegate.swift @@ -0,0 +1,38 @@ +// +// 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 + ) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + print("APNS Token: \"\(token)\"") + FCMService.shared.setAPNSToken(deviceToken) + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + print("APNs 등록 실패: \(error.localizedDescription)") + } +} diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index 5faf3a5..b62cf22 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? @@ -75,6 +77,8 @@ struct DoriApp: App { networkService: networkService, tokenStore: tokenStore ) + $0.fcmPushTestAPIClient = .live(networkService: networkService) + $0.notificationSettingsAPIClient = .live(networkService: networkService) } storeBox.store = store @@ -82,6 +86,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,16 +122,21 @@ struct DoriApp: App { .onOpenURL { url in _ = KakaoSDKHandler.handleOpenURL(url) } + .task { + await FCMService.shared.requestAuthorization() + } } } #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"] ?? "" } @@ -132,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" + } } ) } @@ -144,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/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/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/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 0000000..3f91545 Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/push_disable_bell.imageset/push_disable_bell.png differ 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() diff --git a/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift b/Projects/Feature/MyPage/Sources/DoriToggleSwitch.swift new file mode 100644 index 0000000..83bc3d0 --- /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 = 51 + private let height: CGFloat = 31 + private let thumbSize: CGFloat = 23 + + 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/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 + } + ) +} diff --git a/Projects/Feature/MyPage/Sources/MyPageFeature.swift b/Projects/Feature/MyPage/Sources/MyPageFeature.swift index 8e6e45e..b7043c5 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,35 @@ public struct MyPageFeature { public var isLogoutAlertPresented: Bool 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 + toastItem: DoriToast? = nil, + notificationSettings: NotificationSettingsFeature.State = NotificationSettingsFeature.State(), + fcmPushTest: FCMPushTestFeature.State = FCMPushTestFeature.State() ) { self.navigationPath = [] self.isLoading = isLoading self.isLogoutAlertPresented = isLogoutAlertPresented self.isWithdrawAlertPresented = isWithdrawAlertPresented self.toastItem = toastItem + self.notificationSettings = notificationSettings + self.fcmPushTest = fcmPushTest } } public enum Action: Equatable, Sendable { case onAppear case privacyPolicyTapped + case notificationSettingsTapped + case fcmPushTestTapped case navigationPathChanged([Route]) + case notificationSettings(NotificationSettingsFeature.Action) + case fcmPushTest(FCMPushTestFeature.Action) case logoutButtonTapped case withdrawButtonTapped @@ -73,6 +84,8 @@ public struct MyPageFeature { public enum Route: Hashable, Sendable { case privacyPolicy + case notificationSettings + case fcmPushTest } public func reduce(into state: inout State, action: Action) -> Effect { @@ -84,10 +97,37 @@ public struct MyPageFeature { state.navigationPath.append(.privacyPolicy) return .none + case .notificationSettingsTapped: + 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 + 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 .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 @@ -264,7 +304,7 @@ extension MyPageAPIClient: TestDependencyKey { public extension MyPageAPIClient { static func live( networkService: any NetworkService, - tokenStore: any AuthTokenStoring + tokenStore: KeychainAuthTokenStore ) -> Self { Self( logout: { @@ -289,6 +329,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..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 @@ -31,6 +32,10 @@ public struct MyPageView: View { VStack(alignment: .leading, spacing: 24) { settingInfoView accountInfoView + notificationInfoView + if BuildEnvironment.current.isTestingEnabled { + debugInfoView + } Spacer() } @@ -81,6 +86,20 @@ public struct MyPageView: View { navigationTitle: "개인정보처리방침", url: Self.privacyPolicyURL ) + case .notificationSettings: + NotificationSettingsView( + store: store.scope( + state: \.notificationSettings, + action: \.notificationSettings + ) + ) + case .fcmPushTest: + FCMPushTestView( + store: store.scope( + state: \.fcmPushTest, + action: \.fcmPushTest + ) + ) } } } @@ -130,8 +149,44 @@ 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 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( 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..6717a28 --- /dev/null +++ b/Projects/Feature/MyPage/Sources/NotificationSettingsFeature.swift @@ -0,0 +1,318 @@ +// +// NotificationSettingsFeature.swift +// Dori-iOS +// +// Created by 강동영 on 3/19/26. +// + +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, + 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) + + public enum Delegate: Equatable, Sendable { + case didTapBack + } + } + + 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 updateSetting(.doriAlert, enabled: value, previousValue: previous) + + case .recordReminderToggled(let value): + let previous = state.isRecordReminderEnabled + state.isRecordReminderEnabled = value + return updateSetting(.recordRemind, enabled: value, previousValue: previous) + + case .monthlyToggled(let value): + let previous = state.isMonthlyEnabled + state.isMonthlyEnabled = value + return updateSetting(.monthlySummary, enabled: value, previousValue: previous) + + case .relationBalanceToggled(let value): + let previous = state.isRelationBalanceEnabled + state.isRelationBalanceEnabled = value + 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: + return .send(.delegate(.didTapBack)) + + case .delegate: + 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 new file mode 100644 index 0000000..e22ea04 --- /dev/null +++ b/Projects/Feature/MyPage/Sources/NotificationSettingsView.swift @@ -0,0 +1,227 @@ +// +// NotificationSettingsView.swift +// Dori-iOS +// +// Created by 강동영 on 3/19/26. +// + +import ComposableArchitecture +import DoriDesignSystem +import SwiftUI + +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 + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if !store.isSystemNotificationEnabled { + systemNotificationDisabledBanner + } + + // 전체 푸시 수신 (Type 1: HStack { VStack { title, description }, switch }) + notificationRowWithDescription( + title: "앱 알림 받기", + description: allPushDescrition, + 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) + } + ) + .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 } + + @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) + } +} + +private extension Color { + static let settingColor: Color = .init(red: 100/255, green: 130/255, blue: 173/255) +} + +#Preview { + NavigationStack { + NotificationSettingsView( + store: Store(initialState: NotificationSettingsFeature.State()) { + NotificationSettingsFeature() + } + ) + } +} diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift index 319fb14..0dc249c 100644 --- a/Projects/Feature/Project.swift +++ b/Projects/Feature/Project.swift @@ -61,6 +61,8 @@ let project = Project.dori( dependencies: [ DoriModules.designSystem.module.projectDependency, DoriModules.network.module.projectDependency, + DoriModules.keychain.module.projectDependency, + DoriModules.core.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..f903d8b --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Endpoints/FCMEndpoints.swift @@ -0,0 +1,60 @@ +// +// 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] + } +} + +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 +} + 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 + } +} 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..4e324cf --- /dev/null +++ b/Projects/Platform/FCM/Sources/FCMService.swift @@ -0,0 +1,90 @@ +// +// 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 } + print("FCM Token: \"\(token)\"") + 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..0d92c8e 100644 --- a/Projects/Platform/Project.swift +++ b/Projects/Platform/Project.swift @@ -26,5 +26,14 @@ let project = Project.dori( DoriModules.network.module.projectDependency, ] ), + .doriFramework( + DoriModules.fcm.module, + dependencies: [ + .external(.firebaseCore), + .external(.firebaseMessaging), + .external(.composableArchitecture), + ], + settings: .frameworkSettingsWithObjC + ), ] ) 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/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 396b866..4a0bb1d 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Extension.swift @@ -10,13 +10,15 @@ 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", @@ -28,8 +30,11 @@ extension InfoPlist { "kakaokompassauth", "kakaolink", ], + "UISupportedInterfaceOrientations": [ + "UIInterfaceOrientationPortrait", + ], ] - + public static func baseInfoPlist() -> InfoPlist { return .extendingDefault(with: commonDictionary) } diff --git a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift index cf63d1d..45ca18a 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,23 +93,28 @@ 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] = [ "PRODUCT_NAME": .string(BuildConfiguration.debug.appName), + "APP_DISPLAY_NAME": .string(BuildConfiguration.debug.appName), "ENABLE_TESTABILITY": "YES", "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), + "APP_DISPLAY_NAME": .string(Environment.App.displayName), "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 8f6d765..1dfc1c5 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 ) 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 }