diff --git a/src/Projects/BKData/Sources/API/UserAPI.swift b/src/Projects/BKData/Sources/API/UserAPI.swift index 4faa6ad6..10379a82 100644 --- a/src/Projects/BKData/Sources/API/UserAPI.swift +++ b/src/Projects/BKData/Sources/API/UserAPI.swift @@ -5,6 +5,8 @@ import Foundation enum UserAPI { case me case termsAgreement(termsAgreed: Bool) + case upsertFCMToken(fcmToken: String) + case upsertNotificationSettings(notificationEnabled: Bool) } extension UserAPI: RequestTarget { @@ -18,6 +20,10 @@ extension UserAPI: RequestTarget { return "" case .termsAgreement: return "/terms-agreement" + case .upsertFCMToken: + return "/fcm-token" + case .upsertNotificationSettings: + return "/notification-settings" } } @@ -25,14 +31,14 @@ extension UserAPI: RequestTarget { switch self { case .me: return .get - case .termsAgreement: + case .termsAgreement, .upsertFCMToken, .upsertNotificationSettings: return .put } } var headers: [String: String] { switch self { - case .termsAgreement: + case .termsAgreement, .upsertFCMToken, .upsertNotificationSettings: return [ "Content-Type": "application/json" ] @@ -45,6 +51,10 @@ extension UserAPI: RequestTarget { switch self { case .termsAgreement(let termsAgreed): return TermsAgreementRequestDTO(termsAgreed: termsAgreed) + case .upsertFCMToken(let fcmToken): + return UpsertFCMTokenRequestDTO(fcmToken: fcmToken) + case .upsertNotificationSettings(let notificationEnabled): + return NotificationStatusRequestDTO(notificationEnabled: notificationEnabled) case .me: return nil } diff --git a/src/Projects/BKData/Sources/DTO/Request/NotificationStatusRequestDTO.swift b/src/Projects/BKData/Sources/DTO/Request/NotificationStatusRequestDTO.swift new file mode 100644 index 00000000..9c7658e7 --- /dev/null +++ b/src/Projects/BKData/Sources/DTO/Request/NotificationStatusRequestDTO.swift @@ -0,0 +1,7 @@ +// Copyright © 2025 Booket. All rights reserved + +import Foundation + +struct NotificationStatusRequestDTO: Encodable { + let notificationEnabled: Bool +} diff --git a/src/Projects/BKData/Sources/DTO/Request/UpsertFCMTokenRequestDTO.swift b/src/Projects/BKData/Sources/DTO/Request/UpsertFCMTokenRequestDTO.swift new file mode 100644 index 00000000..ef2788db --- /dev/null +++ b/src/Projects/BKData/Sources/DTO/Request/UpsertFCMTokenRequestDTO.swift @@ -0,0 +1,7 @@ +// Copyright © 2025 Booket. All rights reserved + +import Foundation + +struct UpsertFCMTokenRequestDTO: Encodable { + let fcmToken: String +} diff --git a/src/Projects/BKData/Sources/DTO/Response/UserProfileResponseDTO.swift b/src/Projects/BKData/Sources/DTO/Response/UserProfileResponseDTO.swift index 17c56971..36ddb85e 100644 --- a/src/Projects/BKData/Sources/DTO/Response/UserProfileResponseDTO.swift +++ b/src/Projects/BKData/Sources/DTO/Response/UserProfileResponseDTO.swift @@ -9,6 +9,7 @@ public struct UserProfileResponseDTO: Decodable { public let nickname: String public let provider: String public let termsAgreed: Bool + public let notificationEnabled: Bool public func toUserProfile() -> UserProfile { return UserProfile( @@ -16,7 +17,8 @@ public struct UserProfileResponseDTO: Decodable { email: self.email, nickname: self.nickname, provider: self.provider, - termsAgreed: self.termsAgreed + termsAgreed: self.termsAgreed, + notificationEnabled: self.notificationEnabled ) } } diff --git a/src/Projects/BKData/Sources/DataAssembly.swift b/src/Projects/BKData/Sources/DataAssembly.swift index 49e23ea6..a46b84dc 100644 --- a/src/Projects/BKData/Sources/DataAssembly.swift +++ b/src/Projects/BKData/Sources/DataAssembly.swift @@ -129,5 +129,23 @@ public struct DataAssembly: Assembly { @Autowired var networkProvider: NetworkProvider return DefaultAppStoreRepository(networkProvider: networkProvider) } + + container.register( + type: NotificationRepository.self + ) { _ in + @Autowired(name: "OAuth") var networkProvider: NetworkProvider + return DefaultNotificationRepository(networkProvider: networkProvider) + } + + container.register( + type: PushTokenRepository.self + ) { _ in + @Autowired var pushTokenProvider: PushTokenProvider + @Autowired var pushTokenStore: PushTokenStore + return DefaultPushTokenRepository( + pushTokenProvider: pushTokenProvider, + pushTokenStore: pushTokenStore + ) + } } } diff --git a/src/Projects/BKData/Sources/Interface/Storage/PushTokenProvider.swift b/src/Projects/BKData/Sources/Interface/Storage/PushTokenProvider.swift new file mode 100644 index 00000000..855e1263 --- /dev/null +++ b/src/Projects/BKData/Sources/Interface/Storage/PushTokenProvider.swift @@ -0,0 +1,7 @@ +// Copyright © 2025 Booket. All rights reserved + +public protocol PushTokenProvider { + var fcmToken: String? { get } + var isSyncNeeded: Bool? { get } + func clearCache() +} diff --git a/src/Projects/BKData/Sources/Interface/Storage/PushTokenStore.swift b/src/Projects/BKData/Sources/Interface/Storage/PushTokenStore.swift new file mode 100644 index 00000000..f725d831 --- /dev/null +++ b/src/Projects/BKData/Sources/Interface/Storage/PushTokenStore.swift @@ -0,0 +1,14 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKDomain +import Combine + +public protocol PushTokenStore { + func save( + fcmToken: String + ) -> AnyPublisher + + func resetSyncNeeded() -> AnyPublisher + + func clear() -> AnyPublisher +} diff --git a/src/Projects/BKData/Sources/Repository/DefaultNotificationRepository.swift b/src/Projects/BKData/Sources/Repository/DefaultNotificationRepository.swift new file mode 100644 index 00000000..99a88aea --- /dev/null +++ b/src/Projects/BKData/Sources/Repository/DefaultNotificationRepository.swift @@ -0,0 +1,41 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKCore +import BKDomain +import Combine + +public struct DefaultNotificationRepository: NotificationRepository { + private let networkProvider: NetworkProvider + + public init(networkProvider: NetworkProvider) { + self.networkProvider = networkProvider + } + + public func upsertFCMToken( + fcmToken: String + ) -> AnyPublisher { + networkProvider.request( + target: UserAPI.upsertFCMToken(fcmToken: fcmToken), + type: UserProfileResponseDTO.self + ) + .debugError(logger: AppLogger.network) + .mapError { $0.toDomainError() } + .map { _ in } + .eraseToAnyPublisher() + } + + public func upsertNotificationSettings( + notificationSettings: Bool + ) -> AnyPublisher { + networkProvider.request( + target: UserAPI.upsertNotificationSettings( + notificationEnabled: notificationSettings + ), + type: UserProfileResponseDTO.self + ) + .debugError(logger: AppLogger.network) + .mapError { $0.toDomainError() } + .map { $0.notificationEnabled } + .eraseToAnyPublisher() + } +} diff --git a/src/Projects/BKData/Sources/Repository/DefaultPushTokenRepository.swift b/src/Projects/BKData/Sources/Repository/DefaultPushTokenRepository.swift new file mode 100644 index 00000000..dd9dcc12 --- /dev/null +++ b/src/Projects/BKData/Sources/Repository/DefaultPushTokenRepository.swift @@ -0,0 +1,43 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKCore +import BKDomain +import Combine + +public struct DefaultPushTokenRepository: PushTokenRepository { + private let pushTokenProvider: PushTokenProvider + private let pushTokenStore: PushTokenStore + + public init( + pushTokenProvider: PushTokenProvider, + pushTokenStore: PushTokenStore + ) { + self.pushTokenProvider = pushTokenProvider + self.pushTokenStore = pushTokenStore + } + + public func getFCMToken() -> String? { + return pushTokenProvider.fcmToken + } + + public func isSyncNeeded() -> Bool { + return pushTokenProvider.isSyncNeeded ?? false + } + + public func resetSyncNeeded() -> AnyPublisher { + return pushTokenStore + .resetSyncNeeded() + .map { _ in + self.pushTokenProvider.clearCache() + } + .mapError { error in + Log.error("Failed to reset isSyncNeeded flag: \(error)", logger: AppLogger.storage) + return .clientError + } + .eraseToAnyPublisher() + } + + public func clearCache() { + pushTokenProvider.clearCache() + } +} diff --git a/src/Projects/BKDomain/Sources/DomainAssembly.swift b/src/Projects/BKDomain/Sources/DomainAssembly.swift index f62fbbfb..3a15b441 100644 --- a/src/Projects/BKDomain/Sources/DomainAssembly.swift +++ b/src/Projects/BKDomain/Sources/DomainAssembly.swift @@ -228,5 +228,25 @@ public struct DomainAssembly: Assembly { @Autowired var repository: BookRepository return DefaultDeleteBookUseCase(repository: repository) } + + container.register( + type: SyncFCMTokenUseCase.self + ) { _ in + @Autowired var pushTokenRepository: PushTokenRepository + @Autowired var notificationRepository: NotificationRepository + return DefaultSyncFCMTokenUseCase( + pushTokenRepository: pushTokenRepository, + notificationRepository: notificationRepository + ) + } + + container.register( + type: UpdateNotificationSettingsUseCase.self + ) { _ in + @Autowired var notificationRepository: NotificationRepository + return DefaultUpdateNotificationSettingsUseCase( + notificationRepository: notificationRepository + ) + } } } diff --git a/src/Projects/BKDomain/Sources/Entity/UserProfile.swift b/src/Projects/BKDomain/Sources/Entity/UserProfile.swift index 2647f640..c8c0bc3d 100644 --- a/src/Projects/BKDomain/Sources/Entity/UserProfile.swift +++ b/src/Projects/BKDomain/Sources/Entity/UserProfile.swift @@ -8,18 +8,21 @@ public struct UserProfile { public let nickname: String public let provider: String public let termsAgreed: Bool + public let notificationEnabled: Bool public init( id: String, email: String, nickname: String, provider: String, - termsAgreed: Bool + termsAgreed: Bool, + notificationEnabled: Bool ) { self.id = id self.email = email self.nickname = nickname self.provider = provider self.termsAgreed = termsAgreed + self.notificationEnabled = notificationEnabled } } diff --git a/src/Projects/BKDomain/Sources/Error/AuthError.swift b/src/Projects/BKDomain/Sources/Error/AuthError.swift index 72da36e2..e019d3e4 100644 --- a/src/Projects/BKDomain/Sources/Error/AuthError.swift +++ b/src/Projects/BKDomain/Sources/Error/AuthError.swift @@ -10,3 +10,20 @@ public enum AuthError: Error { case termsNotAccepted case unknown } + +public extension AuthError { + func toDomainError() -> DomainError { + switch self { + case .sdkError(message: let message): + return .clientError + case .serverError(message: let message): + return .internalServerError + case .missingToken: + return .unauthorized + case .termsNotAccepted: + return .unauthorized + case .unknown: + return .unknown + } + } +} diff --git a/src/Projects/BKDomain/Sources/Error/DomainError.swift b/src/Projects/BKDomain/Sources/Error/DomainError.swift index 5c6108d9..a7f1fec6 100644 --- a/src/Projects/BKDomain/Sources/Error/DomainError.swift +++ b/src/Projects/BKDomain/Sources/Error/DomainError.swift @@ -7,4 +7,5 @@ public enum DomainError: Error, Equatable { case clientError case internalServerError case timeout + case unknown } diff --git a/src/Projects/BKDomain/Sources/Interface/Repository/NotificationRepository.swift b/src/Projects/BKDomain/Sources/Interface/Repository/NotificationRepository.swift new file mode 100644 index 00000000..fee79d36 --- /dev/null +++ b/src/Projects/BKDomain/Sources/Interface/Repository/NotificationRepository.swift @@ -0,0 +1,13 @@ +// Copyright © 2025 Booket. All rights reserved + +import Combine + +public protocol NotificationRepository { + func upsertFCMToken( + fcmToken: String + ) -> AnyPublisher + + func upsertNotificationSettings( + notificationSettings: Bool + ) -> AnyPublisher +} diff --git a/src/Projects/BKDomain/Sources/Interface/Repository/PushTokenRepository.swift b/src/Projects/BKDomain/Sources/Interface/Repository/PushTokenRepository.swift new file mode 100644 index 00000000..20b4cfde --- /dev/null +++ b/src/Projects/BKDomain/Sources/Interface/Repository/PushTokenRepository.swift @@ -0,0 +1,10 @@ +// Copyright © 2025 Booket. All rights reserved + +import Combine + +public protocol PushTokenRepository { + func getFCMToken() -> String? + func isSyncNeeded() -> Bool + func resetSyncNeeded() -> AnyPublisher + func clearCache() +} diff --git a/src/Projects/BKDomain/Sources/Interface/Usecase/SyncFCMTokenUseCase.swift b/src/Projects/BKDomain/Sources/Interface/Usecase/SyncFCMTokenUseCase.swift new file mode 100644 index 00000000..f1453147 --- /dev/null +++ b/src/Projects/BKDomain/Sources/Interface/Usecase/SyncFCMTokenUseCase.swift @@ -0,0 +1,10 @@ +// Copyright © 2025 Booket. All rights reserved + +import Combine +import Foundation + +/// FCM 토큰이 동기화가 필요한 경우 서버에 업데이트합니다. +/// isSyncNeeded 플래그를 확인하고, 필요시 서버에 전송하며, 성공 시 플래그를 리셋합니다. +public protocol SyncFCMTokenUseCase { + func execute() -> AnyPublisher +} diff --git a/src/Projects/BKDomain/Sources/Interface/Usecase/UpdateNotificationSettingsUseCase.swift b/src/Projects/BKDomain/Sources/Interface/Usecase/UpdateNotificationSettingsUseCase.swift new file mode 100644 index 00000000..227ae11e --- /dev/null +++ b/src/Projects/BKDomain/Sources/Interface/Usecase/UpdateNotificationSettingsUseCase.swift @@ -0,0 +1,9 @@ +// Copyright © 2025 Booket. All rights reserved + +import Combine +import Foundation + +/// 알림 설정을 서버에 업데이트합니다. +public protocol UpdateNotificationSettingsUseCase { + func execute(isEnabled: Bool) -> AnyPublisher +} diff --git a/src/Projects/BKDomain/Sources/UseCase/DefaultSyncFCMTokenUseCase.swift b/src/Projects/BKDomain/Sources/UseCase/DefaultSyncFCMTokenUseCase.swift new file mode 100644 index 00000000..b391110a --- /dev/null +++ b/src/Projects/BKDomain/Sources/UseCase/DefaultSyncFCMTokenUseCase.swift @@ -0,0 +1,40 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKCore +import Combine +import Foundation + +public struct DefaultSyncFCMTokenUseCase: SyncFCMTokenUseCase { + private let pushTokenRepository: PushTokenRepository + private let notificationRepository: NotificationRepository + + public init( + pushTokenRepository: PushTokenRepository, + notificationRepository: NotificationRepository + ) { + self.pushTokenRepository = pushTokenRepository + self.notificationRepository = notificationRepository + } + + public func execute() -> AnyPublisher { + guard pushTokenRepository.isSyncNeeded() else { + return Just(()) + .setFailureType(to: DomainError.self) + .eraseToAnyPublisher() + } + + guard let fcmToken = pushTokenRepository.getFCMToken() else { + return Just(()) + .setFailureType(to: DomainError.self) + .eraseToAnyPublisher() + } + + return notificationRepository + .upsertFCMToken(fcmToken: fcmToken) + .debugError(logger: AppLogger.network) + .flatMap { _ -> AnyPublisher in + return self.pushTokenRepository.resetSyncNeeded() + } + .eraseToAnyPublisher() + } +} diff --git a/src/Projects/BKDomain/Sources/UseCase/DefaultUpdateNotificationSettingsUseCase.swift b/src/Projects/BKDomain/Sources/UseCase/DefaultUpdateNotificationSettingsUseCase.swift new file mode 100644 index 00000000..f1a1013f --- /dev/null +++ b/src/Projects/BKDomain/Sources/UseCase/DefaultUpdateNotificationSettingsUseCase.swift @@ -0,0 +1,32 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKCore +import Combine +import Foundation + +public struct DefaultUpdateNotificationSettingsUseCase: UpdateNotificationSettingsUseCase { + private let notificationRepository: NotificationRepository + + public init( + notificationRepository: NotificationRepository + ) { + self.notificationRepository = notificationRepository + } + + public func execute(isEnabled: Bool) -> AnyPublisher { + return notificationRepository + .upsertNotificationSettings(notificationSettings: isEnabled) + .debugError(logger: AppLogger.network) + .mapError { error in + switch error { + case .internalServerError: + return .internalServerError + case .unauthorized: + return .unauthorized + default: + return .clientError + } + } + .eraseToAnyPublisher() + } +} diff --git a/src/Projects/BKPresentation/Sources/AppCoordinator.swift b/src/Projects/BKPresentation/Sources/AppCoordinator.swift index 524b458d..f2d8de5a 100644 --- a/src/Projects/BKPresentation/Sources/AppCoordinator.swift +++ b/src/Projects/BKPresentation/Sources/AppCoordinator.swift @@ -6,6 +6,7 @@ import BKDesign import Combine import Foundation import UIKit +import UserNotifications public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying, ScreenLoggable { public var screenName: String = GATracking.OnboardingAndAuth.splash @@ -18,24 +19,28 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying, private let onboardingCheckUseCase: OnboardingCheckUseCase private let markOnboardingSeenUseCase: MarkOnboardingSeenUseCase private let appVersionUseCase: AppVersionUseCase + private let syncFCMTokenUseCase: SyncFCMTokenUseCase private var cancellable: Set = [] - + public init( navigationController: UINavigationController, authStateUseCase: AuthStateUseCase, onboardingCheckUseCase: OnboardingCheckUseCase, markOnboardingSeenUseCase: MarkOnboardingSeenUseCase, - appVersionUseCase: AppVersionUseCase + appVersionUseCase: AppVersionUseCase, + syncFCMTokenUseCase: SyncFCMTokenUseCase ) { self.navigationController = navigationController self.authStateUseCase = authStateUseCase self.onboardingCheckUseCase = onboardingCheckUseCase self.markOnboardingSeenUseCase = markOnboardingSeenUseCase self.appVersionUseCase = appVersionUseCase + self.syncFCMTokenUseCase = syncFCMTokenUseCase } public func start() { logGoogleAnalytics() + upsertFCMTokenIfNeeded() checkAppUpdate() } @@ -43,6 +48,22 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying, presentAuthFlow(animated: true, onFinishAuth: onFinish) } + private func upsertFCMTokenIfNeeded() { + syncFCMTokenUseCase.execute() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + Log.error("Failed to sync FCM token: \(error)", logger: AppLogger.network) + } + }, + receiveValue: { _ in + Log.debug("FCM token sync completed", logger: AppLogger.network) + } + ) + .store(in: &cancellable) + } + private func checkAppUpdate() { Publishers.Zip( appVersionUseCase.execute().setFailureType(to: Error.self), @@ -100,9 +121,10 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying, loginCoordinator.onFinish = { [weak self] in guard let self else { return } - + self.navigationController.dismiss(animated: animated) AccessModeCenter.shared.mode.send(.member) + self.requestNotificationPermissionIfNeeded() self.checkAuthAndRoute() onFinishAuth?() } @@ -190,11 +212,25 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying, leftButtonAction: AppStoreLinker.openAppStore ) ) - + let dialogViewController = BKDialogViewController(dialog: dialog) dialogViewController.isModalInPresentation = true DispatchQueue.main.async { self.navigationController.present(dialogViewController, animated: true) } } + + private func requestNotificationPermissionIfNeeded() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + // 아직 권한을 요청하지 않았을 때만 요청 + guard settings.authorizationStatus == .notDetermined else { return } + + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in + guard granted else { return } + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + } } diff --git a/src/Projects/BKPresentation/Sources/AuthFlow/Coordinator/TermsCoordinator.swift b/src/Projects/BKPresentation/Sources/AuthFlow/Coordinator/TermsCoordinator.swift index 139e2b06..51dd07c0 100644 --- a/src/Projects/BKPresentation/Sources/AuthFlow/Coordinator/TermsCoordinator.swift +++ b/src/Projects/BKPresentation/Sources/AuthFlow/Coordinator/TermsCoordinator.swift @@ -24,7 +24,7 @@ final class TermsCoordinator: Coordinator, FinishNotifying { } } -extension TermsCoordinator: AuthenticationRequiredNotifying, ErrorHandleable, WebPresenting { +extension TermsCoordinator: AuthenticationRequiredNotifying, ErrorHandleable, URLPresenting { func notifyAuthenticationRequired(onFinish: (() -> Void)?) { (parentCoordinator as? AuthenticationRequiredNotifying)?.notifyAuthenticationRequired(onFinish: onFinish) } diff --git a/src/Projects/BKPresentation/Sources/Common/Coordinator/ErrorHandleable.swift b/src/Projects/BKPresentation/Sources/Common/Coordinator/ErrorHandleable.swift index b83b66e9..b50c5ed4 100644 --- a/src/Projects/BKPresentation/Sources/Common/Coordinator/ErrorHandleable.swift +++ b/src/Projects/BKPresentation/Sources/Common/Coordinator/ErrorHandleable.swift @@ -15,7 +15,7 @@ extension ErrorHandleable where Self: Coordinator & AuthenticationRequiredNotify } else { presentGuestAuthErrorAlert() } - case .internalServerError, .clientError: + case .internalServerError, .clientError, .unknown: presentServerErrorAlert() case .timeout: presentTimeoutAlert() diff --git a/src/Projects/BKPresentation/Sources/Common/Coordinator/WebPresenting.swift b/src/Projects/BKPresentation/Sources/Common/Coordinator/URLPresenting.swift similarity index 67% rename from src/Projects/BKPresentation/Sources/Common/Coordinator/WebPresenting.swift rename to src/Projects/BKPresentation/Sources/Common/Coordinator/URLPresenting.swift index 5e0c530e..c7654258 100644 --- a/src/Projects/BKPresentation/Sources/Common/Coordinator/WebPresenting.swift +++ b/src/Projects/BKPresentation/Sources/Common/Coordinator/URLPresenting.swift @@ -3,20 +3,30 @@ import SafariServices import UIKit -protocol WebPresenting { +protocol URLPresenting { var navigationController: UINavigationController { get } } -extension WebPresenting { +extension URLPresenting { func presentWeb( - url: URL?, - entersReaderIfAvailable: Bool = false + url: URL? ) { guard let url else { return } let safari = SFSafariViewController(url: url) - safari.configuration.entersReaderIfAvailable = entersReaderIfAvailable navigationController.present(safari, animated: true) } + + func presentApp( + url: URL? + ) { + guard let url else { return } + + if url.scheme?.lowercased() == "https" { + presentWeb(url: url) + } else if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } } enum DocsType { diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Setting/Coordinator/SettingCoordinator.swift b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Coordinator/SettingCoordinator.swift index 58400fab..23f032f5 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Setting/Coordinator/SettingCoordinator.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Coordinator/SettingCoordinator.swift @@ -23,7 +23,18 @@ final class SettingCoordinator: Coordinator { } } -extension SettingCoordinator: ErrorHandleable, WebPresenting, AuthenticationRequiredNotifying { +extension SettingCoordinator { + func didTapNotificationSetting() { + let notificationCoordinator = NotificationSettingsCoordinator( + parentCoordinator: self, + navigationController: navigationController + ) + addChildCoordinator(notificationCoordinator) + notificationCoordinator.start() + } +} + +extension SettingCoordinator: ErrorHandleable, URLPresenting, AuthenticationRequiredNotifying { func notifyAuthenticationRequired(onFinish: (() -> Void)?) { (parentCoordinator as? AuthenticationRequiredNotifying)?.notifyAuthenticationRequired(onFinish: onFinish) } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/Coordinator/NotificationSettingsCoordinator.swift b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/Coordinator/NotificationSettingsCoordinator.swift new file mode 100644 index 00000000..1dcb82c8 --- /dev/null +++ b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/Coordinator/NotificationSettingsCoordinator.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 Booket. All rights reserved + +import UIKit + +final class NotificationSettingsCoordinator: Coordinator { + var parentCoordinator: Coordinator? + var childCoordinators = [Coordinator]() + var navigationController: UINavigationController + + init( + parentCoordinator: Coordinator?, + navigationController: UINavigationController + ) { + self.parentCoordinator = parentCoordinator + self.navigationController = navigationController + } + + func start() { + let notificationViewController = NotificationSettingsViewController( + viewModel: NotificationSettingsViewModel() + ) + notificationViewController.coordinator = self + navigationController.pushViewController(notificationViewController, animated: true) + } +} + +extension NotificationSettingsCoordinator: URLPresenting {} diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/View/NotificationSettingsView.swift b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/View/NotificationSettingsView.swift new file mode 100644 index 00000000..81cd7f90 --- /dev/null +++ b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/View/NotificationSettingsView.swift @@ -0,0 +1,253 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKDesign +import SnapKit +import UIKit + +final class NotificationSettingsView: BaseView { + // MARK: - Closures + var onNotificationToggleChanged: ((Bool) -> Void)? + var onPermissionRequestViewTapped: (() -> Void)? + + // MARK: - UI Components + private let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.alwaysBounceVertical = true + scrollView.showsVerticalScrollIndicator = false + return scrollView + }() + + private let contentView = UIView() + + private let permissionRequestContainer = UIView() + private let permissionRequestView = UIView() + private let permissionRequestLabelStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = LayoutConstants.permissionRequestStackViewSpacing + stackView.alignment = .leading + return stackView + }() + + private let permissionRequestTitle = BKLabel( + text: "알림을 켜주세요.", + fontStyle: .body1(weight: .semiBold), + color: .bkContentColor(.brand) + ) + + private let permissionRequestContent = BKLabel( + text: """ + 기기 설정에서 Reed 알림을 설정하세요. + 독서 기록에 도움되는 알림을 받을 수 있어요. + """, + fontStyle: .label2(weight: .regular), + color: .bkContentColor(.tertiary) + ) + + private let permissionRequestButton: UIImageView = { + let imageView = UIImageView(image: BKImage.Icon.chevronRight) + imageView.tintColor = .bkContentColor(.brand) + return imageView + }() + + private let permissionToggleContainer = UIView() + private let permissionToggleLabelStack: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = .zero + stackView.alignment = .leading + return stackView + }() + + private let permissionToggleTitle = BKLabel( + text: "알림 받기", + fontStyle: .body1(weight: .medium), + color: .bkContentColor(.primary) + ) + + private let permissionToggleContent = BKLabel( + text: "리드에서 알림을 보내드려요.", + fontStyle: .label1(weight: .regular), + color: .bkContentColor(.tertiary) + ) + + private let permissionToggle: UISwitch = { + let toggle = UISwitch() + toggle.onTintColor = .bkBackgroundColor(.primary) + return toggle + }() + + private var permissionToggleContainerTopToRequestConstraint: Constraint? + private var permissionToggleContainerTopToSafeAreaConstraint: Constraint? + private var isAnimating = false + + override func setupView() { + addSubview(scrollView) + scrollView.addSubview(contentView) + contentView.addSubviews(permissionRequestContainer, permissionToggleContainer) + + permissionRequestContainer.addSubview(permissionRequestView) + permissionRequestView.addSubviews(permissionRequestLabelStack, permissionRequestButton) + [permissionRequestTitle, permissionRequestContent] + .forEach(permissionRequestLabelStack.addArrangedSubview(_:)) + + permissionToggleContainer.addSubviews(permissionToggleLabelStack, permissionToggle) + [permissionToggleTitle, permissionToggleContent] + .forEach(permissionToggleLabelStack.addArrangedSubview(_:)) + } + + override func setupLayout() { + scrollView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + contentView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalToSuperview() + } + + permissionRequestContainer.snp.makeConstraints { + $0.top.equalTo(contentView.safeAreaLayoutGuide.snp.top) + $0.leading.trailing.equalToSuperview() + } + + permissionRequestView.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + .inset(LayoutConstants.permissionRequestViewVerticalInset) + $0.horizontalEdges.equalToSuperview() + .inset(LayoutConstants.permissionRequestViewHorizontalInset) + } + + permissionRequestLabelStack.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + .inset(LayoutConstants.permissionRequestLabelPadding) + $0.leading.equalToSuperview() + .inset(LayoutConstants.commonHorizontalInset) + } + + permissionRequestButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview() + .inset(LayoutConstants.commonHorizontalInset) + } + + permissionToggleContainer.snp.makeConstraints { + self.permissionToggleContainerTopToRequestConstraint = + $0.top + .equalTo(permissionRequestContainer.snp.bottom) + .offset(LayoutConstants.toggleSpacingWithRequest) + .constraint + self.permissionToggleContainerTopToSafeAreaConstraint = + $0.top + .equalTo(contentView.safeAreaLayoutGuide.snp.top) + .inset(LayoutConstants.toggleEmptySpacing) + .constraint + $0.leading.trailing.equalToSuperview() + .inset(LayoutConstants.commonHorizontalInset) + $0.bottom.lessThanOrEqualToSuperview() + } + + permissionToggleContainerTopToRequestConstraint?.activate() + permissionToggleContainerTopToSafeAreaConstraint?.deactivate() + + permissionToggleLabelStack.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + .inset(LayoutConstants.permissionToggleLabelPadding) + $0.leading.equalToSuperview() + } + + permissionToggle.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview() + } + } + + override func configure() { + permissionRequestContent.numberOfLines = .zero + permissionRequestView.backgroundColor = .bkBaseColor(.secondary) + permissionRequestView.layer.cornerRadius = BKRadius.medium + permissionToggle.addTarget(self, action: #selector(didTapPermissionToggle), for: .valueChanged) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapPermissionRequestView)) + permissionRequestView.addGestureRecognizer(tapGesture) + permissionRequestView.isUserInteractionEnabled = true + } +} + +// MARK: - Public Methods +extension NotificationSettingsView { + func updateNotificationToggle(isEnabled: Bool, animated: Bool = true) { + guard permissionToggle.isOn != isEnabled else { return } + permissionToggle.setOn(isEnabled, animated: false) + } + + func updatePermissionRequestVisibility(shouldShow: Bool, animated: Bool) { + updatePermissionRequestContainerVisibility(shouldShow: shouldShow, animated: animated) + } +} + +private extension NotificationSettingsView { + @objc + func didTapPermissionToggle() { + onNotificationToggleChanged?(permissionToggle.isOn) + } + + @objc + func didTapPermissionRequestView() { + onPermissionRequestViewTapped?() + } + + func updatePermissionRequestContainerVisibility(shouldShow: Bool, animated: Bool) { + /// 초기엔 애니메이션 없이 즉시 화면 로드 + if !animated { + if shouldShow { + self.permissionToggleContainerTopToSafeAreaConstraint?.deactivate() + self.permissionToggleContainerTopToRequestConstraint?.activate() + self.permissionRequestContainer.isHidden = false + } else { + self.permissionRequestContainer.isHidden = true + self.permissionToggleContainerTopToRequestConstraint?.deactivate() + self.permissionToggleContainerTopToSafeAreaConstraint?.activate() + } + self.layoutIfNeeded() + return + } + + guard !isAnimating else { return } + /// 탭 하여 권한 안내 화면이 나오는 경우 애니메이션이 없으면 부자연스러우므로 애니메이션 적용 + isAnimating = true + + if shouldShow { + UIView.animate(withDuration: 0.3, animations: { + self.permissionToggleContainerTopToSafeAreaConstraint?.deactivate() + self.permissionToggleContainerTopToRequestConstraint?.activate() + self.layoutIfNeeded() + }, completion: { _ in + self.permissionRequestContainer.isHidden = false + self.isAnimating = false + }) + } else { + UIView.animate(withDuration: 0.3, animations: { + self.permissionRequestContainer.isHidden = true + self.permissionToggleContainerTopToRequestConstraint?.deactivate() + self.permissionToggleContainerTopToSafeAreaConstraint?.activate() + self.layoutIfNeeded() + }, completion: { _ in + self.isAnimating = false + }) + } + } +} + +private extension NotificationSettingsView { + enum LayoutConstants { + static let permissionRequestStackViewSpacing = BKInset.inset05 + static let permissionRequestViewVerticalInset = BKInset.inset2 + static let permissionRequestViewHorizontalInset = BKInset.inset5 + static let commonHorizontalInset = BKInset.inset5 + static let toggleEmptySpacing = BKSpacing.spacing4 + static let toggleSpacingWithRequest = BKSpacing.spacing2 + static let permissionToggleLabelPadding = BKSpacing.spacing4 + static let permissionRequestLabelPadding = BKSpacing.spacing6 + } +} diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/View/NotificationSettingsViewController.swift b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/View/NotificationSettingsViewController.swift new file mode 100644 index 00000000..a27822fc --- /dev/null +++ b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/View/NotificationSettingsViewController.swift @@ -0,0 +1,99 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKCore +import Combine +import UIKit +import UserNotifications + +final class NotificationSettingsViewController: BaseViewController { + override var bkNavigationBarStyle: UINavigationController.BKNavigationBarStyle { + return .standard( + viewController: self + ) + } + + override var bkNavigationTitle: String { + return "알림" + } + + weak var coordinator: (NotificationSettingsCoordinator & URLPresenting)? + private var cancellable = Set() + let viewModel: AnyViewBindableViewModel + private var isInitialLoad = true + + init(viewModel: NotificationSettingsViewModel) { + self.viewModel = AnyViewBindableViewModel(viewModel) + super.init() + } + + override func viewDidLoad() { + super.viewDidLoad() + setupViewActions() + observeAppLifecycle() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.tabBarController?.tabBar.isHidden = true + checkNotificationAuthorization() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.tabBarController?.tabBar.isHidden = false + } + + private func setupViewActions() { + contentView.onNotificationToggleChanged = { [weak self] isEnabled in + self?.viewModel.send(.notificationToggleTapped(isEnabled)) + } + + contentView.onPermissionRequestViewTapped = { [weak self] in + self?.openNotificationSettings() + } + } + + private func openNotificationSettings() { + coordinator?.presentApp( + url: URL(string: UIApplication.openNotificationSettingsURLString) + ) + } + + private func observeAppLifecycle() { + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in + self?.checkNotificationAuthorization() + } + .store(in: &cancellable) + } + + private func checkNotificationAuthorization() { + UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in + DispatchQueue.main.async { + let isAuthorized = settings.authorizationStatus == .authorized + self?.viewModel.send(.systemNotificationAuthorizationChecked(isAuthorized)) + } + } + } + + override func bindAction() { + viewModel.send(.onAppear) + } + + override func bindState() { + viewModel.statePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + // 초기 로드시에는 애니메이션 없이 즉시 설정 + let shouldAnimate = !self.isInitialLoad + self.contentView.updateNotificationToggle(isEnabled: state.notificationEnabled, animated: shouldAnimate) + self.contentView.updatePermissionRequestVisibility( + shouldShow: !state.systemNotificationAuthorized, + animated: shouldAnimate + ) + self.isInitialLoad = false + } + .store(in: &cancellable) + } +} diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/ViewModel/NotificationSettingsViewModel.swift b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/ViewModel/NotificationSettingsViewModel.swift new file mode 100644 index 00000000..b1e2fdcd --- /dev/null +++ b/src/Projects/BKPresentation/Sources/MainFlow/Setting/Notification/ViewModel/NotificationSettingsViewModel.swift @@ -0,0 +1,119 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKCore +import BKDomain +import Combine + +final class NotificationSettingsViewModel: BaseViewModel { + struct State: Equatable { + var notificationEnabled: Bool = false + var systemNotificationAuthorized: Bool = false + var isLoading: Bool = false + var error: DomainError? = nil + } + + enum Action { + case onAppear + case systemNotificationAuthorizationChecked(Bool) + case fetchNotificationSettingsSucceeded(Bool) + case notificationToggleTapped(Bool) + case updateNotificationSettingsSucceeded(Bool) + case errorOccurred(DomainError) + case errorHandled + } + + enum SideEffect { + case fetchNotificationSettings + case updateNotificationSettings(Bool) + } + + @Published private var state = State() + private var cancellables = Set() + private let sideEffectSubject = PassthroughSubject() + + @Autowired private var authStateUseCase: AuthStateUseCase + @Autowired private var updateNotificationSettingsUseCase: UpdateNotificationSettingsUseCase + + var statePublisher: AnyPublisher { + $state.eraseToAnyPublisher() + } + + init() { + bindSideEffects() + } + + func send(_ action: Action) { + let (newState, effects) = reduce(action: action, state: state) + state = newState + effects.forEach { sideEffectSubject.send($0) } + } + + func reduce(action: Action, state: State) -> (State, [SideEffect]) { + var newState = state + var effects: [SideEffect] = [] + + switch action { + case .onAppear: + effects.append(.fetchNotificationSettings) + + case .systemNotificationAuthorizationChecked(let isAuthorized): + newState.systemNotificationAuthorized = isAuthorized + + case .fetchNotificationSettingsSucceeded(let isEnabled): + newState.notificationEnabled = isEnabled + + case .notificationToggleTapped(let isEnabled): + newState.notificationEnabled = isEnabled + newState.isLoading = true + effects.append(.updateNotificationSettings(isEnabled)) + + case .updateNotificationSettingsSucceeded(let isEnabled): + newState.notificationEnabled = isEnabled + newState.isLoading = false + + case .errorOccurred(let error): + newState.isLoading = false + newState.error = error + /// 에러 발생시 이전 상태로 롤백 + effects.append(.fetchNotificationSettings) + + case .errorHandled: + newState.error = nil + } + + return (newState, effects) + } + + func handle(_ effect: SideEffect) -> AnyPublisher { + switch effect { + case .fetchNotificationSettings: + return authStateUseCase.execute() + .map { userProfile in + Action.fetchNotificationSettingsSucceeded(userProfile.notificationEnabled) + } + .catch { error in + Just(Action.errorOccurred(error.toDomainError())) + } + .eraseToAnyPublisher() + + case .updateNotificationSettings(let isEnabled): + return updateNotificationSettingsUseCase.execute(isEnabled: isEnabled) + .map { updatedValue in + Action.updateNotificationSettingsSucceeded(updatedValue) + } + .catch { error in + Just(Action.errorOccurred(error)) + } + .eraseToAnyPublisher() + } + } + + private func bindSideEffects() { + sideEffectSubject + .flatMap { [weak self] effect in + self?.handle(effect) ?? Empty().eraseToAnyPublisher() + } + .sink(receiveValue: send(_:)) + .store(in: &cancellables) + } +} diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Setting/View/SettingViewController.swift b/src/Projects/BKPresentation/Sources/MainFlow/Setting/View/SettingViewController.swift index 13462380..cd110b13 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Setting/View/SettingViewController.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Setting/View/SettingViewController.swift @@ -69,6 +69,8 @@ final class SettingViewController: BaseViewController, ScreenLoggab self?.coordinator?.presentWeb(url: DocsType.terms.url) case .license: self?.coordinator?.presentWeb(url: DocsType.licenses.url) + case .notification: + self?.coordinator?.didTapNotificationSetting() case .version: AppStoreLinker.openAppStore() } diff --git a/src/Projects/BKPresentation/Sources/MainFlow/Setting/ViewModel/SettingViewModel.swift b/src/Projects/BKPresentation/Sources/MainFlow/Setting/ViewModel/SettingViewModel.swift index d787d369..fecd16f9 100644 --- a/src/Projects/BKPresentation/Sources/MainFlow/Setting/ViewModel/SettingViewModel.swift +++ b/src/Projects/BKPresentation/Sources/MainFlow/Setting/ViewModel/SettingViewModel.swift @@ -7,6 +7,7 @@ import Foundation enum FirstMenuItem: String, CaseIterable { case privacy = "개인정보 처리방침" + case notification = "알림" case term = "이용약관" case license = "오픈소스 라이선스" case version = "앱 버전" @@ -100,8 +101,10 @@ final class SettingViewModel: BaseViewModel { case .accessModeChanged(let mode): if mode == .member { + newState.firstMenuItems = FirstMenuItem.allCases newState.secondMenuItems = [.logout, .withdraw] } else { + newState.firstMenuItems = FirstMenuItem.allCases.filter { $0 != .notification } newState.secondMenuItems = [.login] } diff --git a/src/Projects/BKStorage/Sources/Constant/StorageKeys.swift b/src/Projects/BKStorage/Sources/Constant/StorageKeys.swift index 5a8796b2..197743f6 100644 --- a/src/Projects/BKStorage/Sources/Constant/StorageKeys.swift +++ b/src/Projects/BKStorage/Sources/Constant/StorageKeys.swift @@ -1,6 +1,8 @@ // Copyright © 2025 Booket. All rights reserved -enum StorageKeys { - static let accessTokenKey = "accessToken" - static let refreshTokenKey = "refreshToken" +public enum StorageKeys { + public static let accessTokenKey = "accessToken" + public static let refreshTokenKey = "refreshToken" + public static let fcmTokenKey = "fcmToken" + public static let isSyncNeededKey = "isSyncNeeded" } diff --git a/src/Projects/BKStorage/Sources/StorageAssembly.swift b/src/Projects/BKStorage/Sources/StorageAssembly.swift index 15337645..6e81fc15 100644 --- a/src/Projects/BKStorage/Sources/StorageAssembly.swift +++ b/src/Projects/BKStorage/Sources/StorageAssembly.swift @@ -40,5 +40,24 @@ public struct StorageAssembly: Assembly { storage: keyValueStorage ) } + + container.register( + type: PushTokenProvider.self, + scope: .singleton + ) { _ in + @Autowired(name: "Keychain") var keyValueStorage: KeyValueStorage + return KeychainPushTokenProvider( + storage: keyValueStorage + ) + } + + container.register( + type: PushTokenStore.self + ) { _ in + @Autowired(name: "Keychain") var keyValueStorage: KeyValueStorage + return KeychainPushTokenStore( + storage: keyValueStorage + ) + } } } diff --git a/src/Projects/BKStorage/Sources/TokenStorage/KeychainPushTokenProvider.swift b/src/Projects/BKStorage/Sources/TokenStorage/KeychainPushTokenProvider.swift new file mode 100644 index 00000000..3cdf3ba3 --- /dev/null +++ b/src/Projects/BKStorage/Sources/TokenStorage/KeychainPushTokenProvider.swift @@ -0,0 +1,48 @@ +// Copyright © 2025 Booket. All rights reserved + +import BKCore +import BKData +import OSLog + +public final class KeychainPushTokenProvider: PushTokenProvider { + private let storage: KeyValueStorage + private var cachedFCMToken: String? + private var cachedIsSyncNeeded: Bool? + + public init(storage: KeyValueStorage) { + self.storage = storage + } + + public var fcmToken: String? { + if let cachedFCMToken { + return cachedFCMToken + } + do { + let token: String = try storage.load(for: StorageKeys.fcmTokenKey) + self.cachedFCMToken = token + return token + } catch { + Log.error("Failed to load fcmToken: \(error)", logger: AppLogger.storage) + return nil + } + } + + public var isSyncNeeded: Bool? { + if let cachedIsSyncNeeded { + return cachedIsSyncNeeded + } + do { + let flag: Bool = try storage.load(for: StorageKeys.isSyncNeededKey) + self.cachedIsSyncNeeded = flag + return flag + } catch { + Log.error("Failed to load isSyncNeeded: \(error)", logger: AppLogger.storage) + return nil + } + } + + public func clearCache() { + cachedFCMToken = nil + cachedIsSyncNeeded = nil + } +} diff --git a/src/Projects/BKStorage/Sources/TokenStorage/KeychainPushTokenStore.swift b/src/Projects/BKStorage/Sources/TokenStorage/KeychainPushTokenStore.swift new file mode 100644 index 00000000..477c7ff7 --- /dev/null +++ b/src/Projects/BKStorage/Sources/TokenStorage/KeychainPushTokenStore.swift @@ -0,0 +1,60 @@ +// Copyright © 2025 Booket. All rights reserved + +import Combine +import BKData + +public struct KeychainPushTokenStore: PushTokenStore { + private let storage: KeyValueStorage + + /// AppDelegate에서 DI 없이 사용할 수 있도록 shared instance 제공 + public static let shared = KeychainPushTokenStore(storage: KeychainKeyValueStorage()) + + public init(storage: KeyValueStorage) { + self.storage = storage + } + + /// FCM Token을 저장하는 시점에서, 서버와의 동기화 여부를 확인합니다. + /// 발급된 FCM Token이 Storage와 다른 경우에만 isSyncNeededKey를 true로 변경하고, 이는 Upsert의 트리거가 됩니다. + public func save(fcmToken: String) -> AnyPublisher { + do { + let existingToken: String? = try? storage.load(for: StorageKeys.fcmTokenKey) + let isTokenChanged = existingToken != fcmToken + try storage.save(fcmToken, for: StorageKeys.fcmTokenKey) + if isTokenChanged { + try storage.save(true, for: StorageKeys.isSyncNeededKey) + } + + return Just(()) + .setFailureType(to: TokenError.self) + .eraseToAnyPublisher() + } catch { + return Fail(error: TokenError.saveFailed(underlying: error)) + .eraseToAnyPublisher() + } + } + + public func resetSyncNeeded() -> AnyPublisher { + do { + try storage.save(false, for: StorageKeys.isSyncNeededKey) + return Just(()) + .setFailureType(to: TokenError.self) + .eraseToAnyPublisher() + } catch { + return Fail(error: TokenError.saveFailed(underlying: error)) + .eraseToAnyPublisher() + } + } + + public func clear() -> AnyPublisher { + do { + try storage.delete(for: StorageKeys.fcmTokenKey) + try storage.delete(for: StorageKeys.isSyncNeededKey) + return Just(()) + .setFailureType(to: TokenError.self) + .eraseToAnyPublisher() + } catch { + return Fail(error: TokenError.clearFailed(underlying: error)) + .eraseToAnyPublisher() + } + } +} diff --git a/src/Projects/Booket/Project.swift b/src/Projects/Booket/Project.swift index 455da979..150b2976 100644 --- a/src/Projects/Booket/Project.swift +++ b/src/Projects/Booket/Project.swift @@ -30,7 +30,8 @@ let debugAppTarget = Target.target( .external(dependency: .PulseProxy), .external(dependency: .FirebaseCore), .external(dependency: .FirebaseCrashlytics), - .external(dependency: .FirebaseAnalytics) + .external(dependency: .FirebaseAnalytics), + .external(dependency: .FirebaseMessaging) ], settings: .settings( base: [ @@ -73,7 +74,9 @@ let releaseAppTarget = Target.target( .storage(), .domain(), .external(dependency: .FirebaseCore), - .external(dependency: .FirebaseCrashlytics) + .external(dependency: .FirebaseCrashlytics), + .external(dependency: .FirebaseAnalytics), + .external(dependency: .FirebaseMessaging) ], settings: .settings( base: [ diff --git a/src/Projects/Booket/Sources/AppDelegate.swift b/src/Projects/Booket/Sources/AppDelegate.swift index add6caa6..f8cdb349 100644 --- a/src/Projects/Booket/Sources/AppDelegate.swift +++ b/src/Projects/Booket/Sources/AppDelegate.swift @@ -2,8 +2,11 @@ import BKCore import BKData -import KakaoSDKCommon +import BKStorage +import Combine import Firebase +import FirebaseMessaging +import KakaoSDKCommon #if DEBUG import Pulse import PulseProxy @@ -12,6 +15,7 @@ import UIKit @main final class AppDelegate: UIResponder, UIApplicationDelegate { + private var cancellables = Set() func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -25,7 +29,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { #endif KakaoSDK.initSDK(appKey: kakaoAPIkey) FirebaseApp.configure() + GAManager.configureAnalytics() + UNUserNotificationCenter.current().delegate = self + Messaging.messaging().delegate = self + return true } @@ -39,4 +47,43 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { sessionRole: connectingSceneSession.role ) } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Messaging.messaging().apnsToken = deviceToken + } + + /// 정확히 이 시점에서 FCM Token이 생성됩니다. + /// FCM Token이 재발급 되는 시점도 해당 시점입니다. + func messaging( + _ messaging: Messaging, + didReceiveRegistrationToken fcmToken: String? + ) { + guard let token = fcmToken else { return } + + KeychainPushTokenStore.shared.save(fcmToken: token) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + AppLogger.auth.error("Failed to save FCM token: \(error)") + } + }, + receiveValue: { _ in + AppLogger.auth.info("FCM token saved successfully") + } + ) + .store(in: &cancellables) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound]) + } } + +extension AppDelegate: UNUserNotificationCenterDelegate, MessagingDelegate {} diff --git a/src/Projects/Booket/Sources/DebugOptionViewController.swift b/src/Projects/Booket/Sources/DebugOptionViewController.swift index a27c772f..033e3480 100644 --- a/src/Projects/Booket/Sources/DebugOptionViewController.swift +++ b/src/Projects/Booket/Sources/DebugOptionViewController.swift @@ -1,5 +1,6 @@ // Copyright © 2025 Booket. All rights reserved +#if DEBUG import BKCore import BKData import BKDesign @@ -8,10 +9,8 @@ import BKNetwork import BKPresentation import BKStorage import KakaoSDKAuth -#if DEBUG import Pulse import PulseUI -#endif import SwiftUI import UIKit @@ -136,3 +135,4 @@ extension DebugOptionViewController: UITableViewDelegate, UITableViewDataSource selectedOption.performAction(on: self) } } +#endif diff --git a/src/Projects/Booket/Sources/SceneDelegate.swift b/src/Projects/Booket/Sources/SceneDelegate.swift index 9bbaf638..9e18738d 100644 --- a/src/Projects/Booket/Sources/SceneDelegate.swift +++ b/src/Projects/Booket/Sources/SceneDelegate.swift @@ -59,13 +59,15 @@ private extension SceneDelegate { @Autowired var onboardingCheckUseCase: OnboardingCheckUseCase @Autowired var markOnboardingSeenUseCase: MarkOnboardingSeenUseCase @Autowired var appVersionUseCase: AppVersionUseCase - + @Autowired var syncFCMTokenUseCase: SyncFCMTokenUseCase + self.coordinator = AppCoordinator( navigationController: navigationController, authStateUseCase: authStateUseCase, onboardingCheckUseCase: onboardingCheckUseCase, markOnboardingSeenUseCase: markOnboardingSeenUseCase, - appVersionUseCase: appVersionUseCase + appVersionUseCase: appVersionUseCase, + syncFCMTokenUseCase: syncFCMTokenUseCase ) coordinator?.start() } diff --git a/src/SupportingFiles/Booket/Booket.entitlements b/src/SupportingFiles/Booket/Booket.entitlements index a812db50..80b5221d 100644 --- a/src/SupportingFiles/Booket/Booket.entitlements +++ b/src/SupportingFiles/Booket/Booket.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.developer.applesignin Default diff --git a/src/SupportingFiles/Booket/Info.plist b/src/SupportingFiles/Booket/Info.plist index 59f38e42..4ade5c04 100644 --- a/src/SupportingFiles/Booket/Info.plist +++ b/src/SupportingFiles/Booket/Info.plist @@ -22,6 +22,8 @@ APPL CFBundleShortVersionString 1.1.3 + FirebaseAppDelegateProxyEnabled + NO CFBundleURLTypes @@ -35,6 +37,10 @@ CFBundleVersion 1 + FirebaseAutomaticScreenReportingEnabled + + ITSAppUsesNonExemptEncryption + KAKAO_NATIVE_APP_KEY $(KAKAO_NATIVE_APP_KEY) LSApplicationQueriesSchemes @@ -74,6 +80,10 @@ + UIBackgroundModes + + remote-notification + UIDeviceFamily 1 @@ -88,9 +98,5 @@ UIInterfaceOrientationPortrait - ITSAppUsesNonExemptEncryption - - FirebaseAutomaticScreenReportingEnabled - diff --git a/src/Tuist/Package.swift b/src/Tuist/Package.swift index b06251d4..637f341e 100644 --- a/src/Tuist/Package.swift +++ b/src/Tuist/Package.swift @@ -16,7 +16,8 @@ "FirebaseCore" : .staticLibrary, "FirebaseAnalytics" : .staticLibrary, "FirebaseCrashlytics" : .staticLibrary, - "FirebaseRemoteConfig" : .staticLibrary + "FirebaseRemoteConfig" : .staticLibrary, + "FirebaseMessaging": .staticLibrary ] ) #endif diff --git a/src/Tuist/ProjectDescriptionHelpers/TargetDependency+External.swift b/src/Tuist/ProjectDescriptionHelpers/TargetDependency+External.swift index be78330a..5bba6720 100644 --- a/src/Tuist/ProjectDescriptionHelpers/TargetDependency+External.swift +++ b/src/Tuist/ProjectDescriptionHelpers/TargetDependency+External.swift @@ -23,6 +23,7 @@ public enum External: String { case FirebaseCrashlytics case FirebaseAnalyticsSwift case FirebaseRemoteConfig + case FirebaseMessaging case Nimble case Quick