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
}