diff --git a/.gitignore b/.gitignore index 72ac59b..3a2d1d4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ DerivedData/ !default.perspectivev3 Config/ .env - +GoogleService-Info.plist ## Obj-C/Swift specific *.hmap diff --git a/.mise.toml b/.mise.toml index 9806627..77a4b59 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,2 +1,2 @@ [tools] -tuist = "4.68.0" +tuist = "4.113.0" diff --git a/Data/Project.swift b/Data/Project.swift index 89d00b8..3e35c5c 100644 --- a/Data/Project.swift +++ b/Data/Project.swift @@ -8,7 +8,12 @@ let project = Project.makeFramework( .NetworkService, .SPM.Supabase, .SPM.GoogleSignIn, - .SPM.AppAuth + .SPM.FirebaseAnalytics, + .SPM.FirebaseCrashlytics, ], hasTests: true, + settings: .settings( + base: SettingsDictionary() + .otherLinkerFlags(["-all_load", "-ObjC"]), + ) ) diff --git a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift new file mode 100644 index 0000000..1669343 --- /dev/null +++ b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift @@ -0,0 +1,85 @@ +import Foundation +import FirebaseAnalytics +import Domain +import LogMacro + +/// FirebaseAnalytics를 사용한 Analytics Repository 구현체 +public class FirebaseAnalyticsRepository: AnalyticsRepositoryProtocol, @unchecked Sendable { + + public init() { + #logDebug("🔥 [Analytics] ===== FIREBASE ANALYTICS REPOSITORY INITIALIZED =====") + #logDebug("🔥 [Analytics] Sending app_analytics_initialized event...") + + Analytics.logEvent("app_analytics_initialized", parameters: [ + "timestamp": Date().timeIntervalSince1970, + "version": "1.0" + ]) + } + + public func sendEvent(_ event: AnalyticsEvent) async { + switch event { + case .auth(let eventType, let data): + await sendAuthEvent(eventType, data) + case .deeplink(let data): + await sendDeeplinkEvent(data) + case .travel(let eventType, let data): + await sendTravelEvent(eventType, data) + case .expense(let eventType, let data): + await sendExpenseEvent(eventType, data) + } + } + + // MARK: - Private Event Handlers + + private func sendAuthEvent(_ eventType: AuthEventType, _ data: AuthEventData) async { + var params: [String: Any] = ["social_type": data.socialType] + if let isFirst = data.isFirst { + params["is_first"] = isFirst + } + + #logDebug("🔥 [Analytics] Parameters: \(params)") + Analytics.logEvent(eventType.rawValue, parameters: params) + } + + private func sendDeeplinkEvent(_ data: DeeplinkEventData) async { + let parameters: [String: Any] = [ + "deeplink": data.deeplink, + "deeplink_type": data.type + ] + + #logDebug("🔥 [Analytics] Parameters: \(parameters)") + Analytics.logEvent("deeplink_open", parameters: parameters) + #logDebug("🔥 [Analytics] ✅ deeplink_open event sent to Firebase") + } + + private func sendTravelEvent(_ eventType: TravelEventType, _ data: TravelEventData) async { + var params: [String: Any] = ["travel_id": data.travelId] + + // Optional fields based on event type + if let userId = data.userId { params["user_id"] = userId } + if let memberId = data.memberId { params["member_id"] = memberId } + if let role = data.role { params["role"] = role } + if let newOwnerId = data.newOwnerId { params["new_owner_id"] = newOwnerId } + + #logDebug("🔥 [Analytics] Parameters: \(params)") + Analytics.logEvent(eventType.rawValue, parameters: params) + } + + private func sendExpenseEvent(_ eventType: ExpenseEventType, _ data: ExpenseEventData) async { + var params: [String: Any] = ["travel_id": data.travelId] + + // Add optional fields + if let expenseId = data.expenseId { params["expense_id"] = expenseId } + if let amount = data.amount { params["amount"] = amount } + if let currency = data.currency { params["currency"] = currency } + if let category = data.category { params["category"] = category } + if let payerId = data.payerId { params["payer_id"] = payerId } + if let source = data.source { params["source"] = source } + if let tab = data.tab { params["tab"] = tab } + if let expenseDate = data.expenseDate { params["expense_date"] = expenseDate } + if let errorCode = data.errorCode { params["error_code"] = errorCode } + + #logDebug("🔥 [Analytics] Parameters: \(params)") + Analytics.logEvent(eventType.rawValue, parameters: params) + } +} \ No newline at end of file diff --git a/Data/Sources/Repository/Kakao/KakaoOAuthRepository.swift b/Data/Sources/Repository/Kakao/KakaoOAuthRepository.swift index fdafe71..f1a8aae 100644 --- a/Data/Sources/Repository/Kakao/KakaoOAuthRepository.swift +++ b/Data/Sources/Repository/Kakao/KakaoOAuthRepository.swift @@ -71,7 +71,7 @@ public final class KakaoOAuthRepository: NSObject, KakaoOAuthRepositoryProtocol // 카카오톡 설치 시: 톡 앱으로만 진행(웹 세션 표시 없음), 딥링크(ticket/code)는 KakaoAuthCodeStore에서 기다림 if let talkURL = talkAuthorizeURL(from: authorizeURL) { - await UIApplication.shared.open(talkURL, options: [:], completionHandler: nil) + UIApplication.shared.open(talkURL, options: [:], completionHandler: nil) do { let ticket = try await KakaoAuthCodeStore.shared.waitForCode() diff --git a/Domain/Sources/Entity/Analytics/AnalyticsEvent.swift b/Domain/Sources/Entity/Analytics/AnalyticsEvent.swift new file mode 100644 index 0000000..67c6afc --- /dev/null +++ b/Domain/Sources/Entity/Analytics/AnalyticsEvent.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Analytics 이벤트 통합 enum +public enum AnalyticsEvent: Sendable { + case auth(AuthEventType, AuthEventData) + case deeplink(DeeplinkEventData) + case travel(TravelEventType, TravelEventData) + case expense(ExpenseEventType, ExpenseEventData) +} \ No newline at end of file diff --git a/Domain/Sources/Entity/Analytics/AuthEventData.swift b/Domain/Sources/Entity/Analytics/AuthEventData.swift new file mode 100644 index 0000000..2e4fe2c --- /dev/null +++ b/Domain/Sources/Entity/Analytics/AuthEventData.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Authentication 이벤트 데이터 +public struct AuthEventData: Sendable { + public let socialType: String + public let isFirst: Bool? + + public init(socialType: String, isFirst: Bool? = nil) { + self.socialType = socialType + self.isFirst = isFirst + } +} + +/// Authentication 이벤트 타입 +public enum AuthEventType: String, Sendable { + case loginSuccess = "login_success" + case signupSuccess = "signup_success" +} \ No newline at end of file diff --git a/Domain/Sources/Entity/Analytics/DeeplinkEventData.swift b/Domain/Sources/Entity/Analytics/DeeplinkEventData.swift new file mode 100644 index 0000000..cff7bce --- /dev/null +++ b/Domain/Sources/Entity/Analytics/DeeplinkEventData.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Deeplink 이벤트 데이터 +public struct DeeplinkEventData: Sendable { + public let deeplink: String + public let type: String + + public init(deeplink: String, type: String) { + self.deeplink = deeplink + self.type = type + } +} \ No newline at end of file diff --git a/Domain/Sources/Entity/Analytics/ExpenseEventData.swift b/Domain/Sources/Entity/Analytics/ExpenseEventData.swift new file mode 100644 index 0000000..4f41b27 --- /dev/null +++ b/Domain/Sources/Entity/Analytics/ExpenseEventData.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Expense 이벤트 데이터 +public struct ExpenseEventData: Sendable { + public let travelId: String + public let expenseId: String? + public let amount: Double? + public let currency: String? + public let category: String? + public let payerId: String? + public let source: String? + public let tab: String? + public let expenseDate: String? + public let errorCode: String? + + public init( + travelId: String, + expenseId: String? = nil, + amount: Double? = nil, + currency: String? = nil, + category: String? = nil, + payerId: String? = nil, + source: String? = nil, + tab: String? = nil, + expenseDate: String? = nil, + errorCode: String? = nil + ) { + self.travelId = travelId + self.expenseId = expenseId + self.amount = amount + self.currency = currency + self.category = category + self.payerId = payerId + self.source = source + self.tab = tab + self.expenseDate = expenseDate + self.errorCode = errorCode + } +} + +/// Expense 이벤트 타입 +public enum ExpenseEventType: String, Sendable { + case view = "expense_view" + case openDetail = "expense_open_detail" + case createSuccess = "expense_create_success" + case createFailure = "expense_create_failure" + case update = "expense_update" + case delete = "expense_delete" +} \ No newline at end of file diff --git a/Domain/Sources/Entity/Analytics/TravelEventData.swift b/Domain/Sources/Entity/Analytics/TravelEventData.swift new file mode 100644 index 0000000..1d72693 --- /dev/null +++ b/Domain/Sources/Entity/Analytics/TravelEventData.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Travel 이벤트 데이터 +public struct TravelEventData: Sendable { + public let travelId: String + public let userId: String? + public let memberId: String? + public let role: String? + public let newOwnerId: String? + + public init( + travelId: String, + userId: String? = nil, + memberId: String? = nil, + role: String? = nil, + newOwnerId: String? = nil + ) { + self.travelId = travelId + self.userId = userId + self.memberId = memberId + self.role = role + self.newOwnerId = newOwnerId + } +} + +/// Travel 이벤트 타입 +public enum TravelEventType: String, Sendable { + case update = "travel_update" + case delete = "travel_delete" + case leave = "travel_leave" + case memberLeave = "travel_member_leave" + case ownerDelegate = "travel_owner_delegate" +} \ No newline at end of file diff --git a/Domain/Sources/Repository/Analytics/AnalyticsRepositoryProtocol.swift b/Domain/Sources/Repository/Analytics/AnalyticsRepositoryProtocol.swift new file mode 100644 index 0000000..5374ba1 --- /dev/null +++ b/Domain/Sources/Repository/Analytics/AnalyticsRepositoryProtocol.swift @@ -0,0 +1,6 @@ +import Foundation + +/// Analytics 이벤트 전송을 위한 Repository 프로토콜 +public protocol AnalyticsRepositoryProtocol: Sendable { + func sendEvent(_ event: AnalyticsEvent) async +} \ No newline at end of file diff --git a/Domain/Sources/Repository/Analytics/MockAnalyticsRepository.swift b/Domain/Sources/Repository/Analytics/MockAnalyticsRepository.swift new file mode 100644 index 0000000..1d8a988 --- /dev/null +++ b/Domain/Sources/Repository/Analytics/MockAnalyticsRepository.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Mock Analytics Repository for testing +public actor MockAnalyticsRepository: AnalyticsRepositoryProtocol, Sendable { + + // MARK: - Tracking + + private let eventsStorage = ThreadSafeContainer<[AnalyticsEvent]>([]) + + /// 추적된 이벤트들 (테스트에서 확인용) + public var trackedEvents: [AnalyticsEvent] { + eventsStorage.value + } + + public init() {} + + public func sendEvent(_ event: AnalyticsEvent) async { + eventsStorage.modify { $0.append(event) } + } + + /// 추적된 이벤트 초기화 (테스트 간 클린업용) + public func clearTrackedEvents() { + eventsStorage.modify { $0.removeAll() } + } + + /// 특정 타입의 이벤트가 추적되었는지 확인 + public func hasTrackedEvent(type: T.Type) -> Bool { + return trackedEvents.contains { event in + switch event { + case .auth: + return T.self == AuthEventType.self + case .travel: + return T.self == TravelEventType.self + case .expense: + return T.self == ExpenseEventType.self + case .deeplink: + return T.self == DeeplinkEventData.self + } + } + } + + /// Auth 이벤트 추적 개수 + public var authEventCount: Int { + trackedEvents.filter { + if case .auth = $0 { return true } + return false + }.count + } + + /// Travel 이벤트 추적 개수 + public var travelEventCount: Int { + trackedEvents.filter { + if case .travel = $0 { return true } + return false + }.count + } + + /// Expense 이벤트 추적 개수 + public var expenseEventCount: Int { + trackedEvents.filter { + if case .expense = $0 { return true } + return false + }.count + } + + /// Deeplink 이벤트 추적 개수 + public var deeplinkEventCount: Int { + trackedEvents.filter { + if case .deeplink = $0 { return true } + return false + }.count + } +} + +// MARK: - Thread Safety Helper + +private class ThreadSafeContainer: @unchecked Sendable { + private var storage: T + private let lock = NSLock() + + var value: T { + lock.withLock { storage } + } + + init(_ value: T) { + self.storage = value + } + + func modify(_ transform: (inout T) -> Void) { + lock.withLock { + transform(&storage) + } + } +} diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift new file mode 100644 index 0000000..abf9bee --- /dev/null +++ b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift @@ -0,0 +1,31 @@ +import Foundation +import ComposableArchitecture + +/// Analytics UseCase 구현체 +public struct AnalyticsUseCase: AnalyticsUseCaseProtocol, Sendable { + private let repository: any AnalyticsRepositoryProtocol + + public init(repository: any AnalyticsRepositoryProtocol) { + self.repository = repository + } + + public func track(_ event: AnalyticsEvent) { + Task { + await repository.sendEvent(event) + } + } +} + +// MARK: - Dependency Key + +extension AnalyticsUseCase: DependencyKey { + public static let liveValue: any AnalyticsUseCaseProtocol = AnalyticsUseCase(repository: MockAnalyticsRepository()) + public static let testValue: any AnalyticsUseCaseProtocol = AnalyticsUseCase(repository: MockAnalyticsRepository()) +} + +public extension DependencyValues { + var analyticsUseCase: any AnalyticsUseCaseProtocol { + get { self[AnalyticsUseCase.self] } + set { self[AnalyticsUseCase.self] = newValue } + } +} diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsUseCaseProtocol.swift b/Domain/Sources/UseCase/Analytics/AnalyticsUseCaseProtocol.swift new file mode 100644 index 0000000..249aac9 --- /dev/null +++ b/Domain/Sources/UseCase/Analytics/AnalyticsUseCaseProtocol.swift @@ -0,0 +1,12 @@ +// +// AnalyticsUseCaseProtocol.swift +// Domain +// +// Created by Wonji Suh on 12/12/25. +// + +import Foundation + +public protocol AnalyticsUseCaseProtocol: Sendable { + func track(_ event: AnalyticsEvent) +} diff --git a/Features/Login/Sources/Login/Reducer/LoginFeature.swift b/Features/Login/Sources/Login/Reducer/LoginFeature.swift index 130b25d..f4b4a80 100644 --- a/Features/Login/Sources/Login/Reducer/LoginFeature.swift +++ b/Features/Login/Sources/Login/Reducer/LoginFeature.swift @@ -84,6 +84,7 @@ public struct LoginFeature { @Dependency(UnifiedOAuthUseCase.self) var unifiedOAuthUseCase @Dependency(SessionUseCase.self) var sessionUseCase + @Dependency(\.analyticsUseCase) var analyticsUseCase nonisolated enum CancelID: Hashable { @@ -202,6 +203,14 @@ extension LoginFeature { state.authResult = authEntity state.statusMessage = "\(authEntity.provider.rawValue) 인증 성공!" state.$sessionId.withLock { $0 = authEntity.token.sessionID } + // Analytics: 로그인/회원가입 구분 전송 + let social = authEntity.provider.rawValue + if case .signUpSuccess = outcome { + analyticsUseCase.track(.auth(.signupSuccess, AuthEventData(socialType: social))) + analyticsUseCase.track(.auth(.loginSuccess, AuthEventData(socialType: social, isFirst: true))) + } else { + analyticsUseCase.track(.auth(.loginSuccess, AuthEventData(socialType: social, isFirst: false))) + } return .send(.delegate(.presentTravelList)) case .needsTermsAgreement(let authData): diff --git a/Features/Main/Sources/MainCoordinator.swift b/Features/Main/Sources/MainCoordinator.swift index d5b5467..63eda5f 100644 --- a/Features/Main/Sources/MainCoordinator.swift +++ b/Features/Main/Sources/MainCoordinator.swift @@ -102,6 +102,12 @@ extension MainCoordinator { state.routes.push(.memberManage(.init(travelId: travelId))) return .none + case let .routeAction(_, .travelSetting(.delegate(.navigateToTravelDetail(travelId)))): + // 여행 수정 완료 후 해당 여행의 상세 페이지로 이동 + return .routeWithDelaysIfUnsupported(state.routes, action: \.router) { + $0.goBackTo(\.travelList) + } + case .routeAction(_, .memberManage(.delegate(.back))): state.routes.goBack() return .none @@ -134,7 +140,6 @@ extension MainCoordinator { action: DelegateAction ) -> Effect { switch action { - case .presentLogin: return .none } @@ -189,6 +194,28 @@ extension MainCoordinator { } + // settings 경로인 경우 바로 TravelSetting으로 이동 + if remainingComponents.count >= 1, remainingComponents[0] == "settings" { + #logDebug("⚙️ Navigating to travel settings") + // 기존 여행 관련 화면들 정리 + if let settlementIndex = state.routes.lastIndex(where: { + if case .settlementCoordinator = $0.screen { return true } + return false + }) { + state.routes.removeSubrange(settlementIndex...) + } + if let travelSettingIndex = state.routes.lastIndex(where: { + if case .travelSetting = $0.screen { return true } + return false + }) { + state.routes.removeSubrange(travelSettingIndex...) + } + // 여행 설정 페이지로 직접 이동 + state.routes.push(.travelSetting(.init(travelId: travelId))) + return .none + } + + // 일반적인 여행 상세 페이지 처리 let currentTravelId = getCurrentTravelId(from: state) if currentTravelId != travelId { // 다른 여행이거나 여행 화면이 없으면 새로 열기 @@ -206,6 +233,7 @@ extension MainCoordinator { if remainingComponents.count >= 2, remainingComponents[0] == "expense" { let expenseId = remainingComponents[1] #logDebug("💰 Navigating to expense detail: \(expenseId)") + // 지출 목록 탭으로 이동하고 특정 지출을 찾아서 표시 let routeIndex = state.routes.count - 1 return .send(.router(.routeAction(id: routeIndex, action: .settlementCoordinator(.navigateToExpenseTab(expenseId))))) diff --git a/Features/Member/Sources/Reducer/MemberManageFeature.swift b/Features/Member/Sources/Reducer/MemberManageFeature.swift index 5fa20b6..6edac5a 100644 --- a/Features/Member/Sources/Reducer/MemberManageFeature.swift +++ b/Features/Member/Sources/Reducer/MemberManageFeature.swift @@ -67,6 +67,7 @@ public struct MemberManageFeature { @Dependency(\.fetchMemberUseCase) var fetchMemberUseCase @Dependency(\.deleteTravelMemberUseCase) var deleteTravelMemberUseCase @Dependency(\.delegateOwnerUseCase) var delegateOwnerUseCase + @Dependency(\.analyticsUseCase) var analyticsUseCase public var body: some Reducer { Reduce { state, action in @@ -131,6 +132,7 @@ public struct MemberManageFeature { case let .deleteMemberResponse(.success(memberId)): state.members.removeAll { $0.id == memberId } + analyticsUseCase.track(.travel(.memberLeave, TravelEventData(travelId: state.travelId, memberId: memberId))) return .none case .deleteMemberResponse(.failure): @@ -186,6 +188,12 @@ public struct MemberManageFeature { } let excludedId = state.myInfo?.id state.members = travel.members.filter { $0.id != excludedId } + + // 새 관리자 ID 찾기 + if let newOwnerId = travel.members.first(where: { $0.role == .owner })?.id { + analyticsUseCase.track(.travel(.ownerDelegate, TravelEventData(travelId: state.travelId, newOwnerId: newOwnerId))) + } + return .send(.delegate(.finish)) case .delegateOwnerResponse(.failure): diff --git a/Features/Travel/Sources/Reducer/BasicSettingFeature.swift b/Features/Travel/Sources/Reducer/BasicSettingFeature.swift index 9b92579..88b8381 100644 --- a/Features/Travel/Sources/Reducer/BasicSettingFeature.swift +++ b/Features/Travel/Sources/Reducer/BasicSettingFeature.swift @@ -112,6 +112,7 @@ public struct BasicSettingFeature { @Dependency(\.fetchCountriesUseCase) var fetchCountriesUseCase @Dependency(\.fetchExchangeRateUseCase) var fetchExchangeRateUseCase @Dependency(\.updateTravelUseCase) var updateTravelUseCase + @Dependency(\.analyticsUseCase) var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -282,6 +283,8 @@ public struct BasicSettingFeature { state.selectedCurrency = updated.baseCurrency state.exchangeRate = String(updated.baseExchangeRate) + analyticsUseCase.track(.travel(.update, TravelEventData(travelId: updated.id))) + return .merge( .send(.updated(state.travel)), .run { _ in diff --git a/Features/Travel/Sources/Reducer/TravelManageFeature.swift b/Features/Travel/Sources/Reducer/TravelManageFeature.swift index b4e39ea..9c1ccf7 100644 --- a/Features/Travel/Sources/Reducer/TravelManageFeature.swift +++ b/Features/Travel/Sources/Reducer/TravelManageFeature.swift @@ -49,6 +49,7 @@ public struct TravelManageFeature { @Dependency(\.leaveTravelUseCase) var leaveTravelUseCase @Dependency(\.deleteTravelUseCase) var deleteTravelUseCase + @Dependency(\.analyticsUseCase) var analyticsUseCase public var body: some Reducer { Reduce { state, action in @@ -79,6 +80,7 @@ public struct TravelManageFeature { case .leaveResponse(.success): state.isSubmitting = false + analyticsUseCase.track(.travel(.leave, TravelEventData(travelId: state.travelId, userId: nil))) return .send(.dismissRequested) case .leaveResponse(.failure(let err)): @@ -104,6 +106,7 @@ public struct TravelManageFeature { case .deleteResponse(.success): state.isSubmitting = false + analyticsUseCase.track(.travel(.delete, TravelEventData(travelId: state.travelId))) return .send(.dismissRequested) case .deleteResponse(.failure(let err)): diff --git a/Features/Travel/Sources/Reducer/TravelSettingFeature.swift b/Features/Travel/Sources/Reducer/TravelSettingFeature.swift index bb7e503..9523832 100644 --- a/Features/Travel/Sources/Reducer/TravelSettingFeature.swift +++ b/Features/Travel/Sources/Reducer/TravelSettingFeature.swift @@ -52,6 +52,7 @@ public struct TravelSettingFeature { public enum Delegate: Equatable { case done case openMemberManage(travelId: String) + case navigateToTravelDetail(travelId: String) } } diff --git a/SseuDamApp/Sources/Application/AppDelegate.swift b/SseuDamApp/Sources/Application/AppDelegate.swift index baf9640..367278a 100644 --- a/SseuDamApp/Sources/Application/AppDelegate.swift +++ b/SseuDamApp/Sources/Application/AppDelegate.swift @@ -9,6 +9,9 @@ import UIKit import UserNotifications import LogMacro import Data +import Firebase +import FirebaseAnalytics + @MainActor @@ -19,17 +22,24 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil ) -> Bool { + #if DEBUG + setenv("FIRAnalyticsDebugEnabled", "1", 1) + setenv("FIRDebugEnabled", "1", 1) + #endif + + FirebaseApp.configure() + Analytics.setAnalyticsCollectionEnabled(true) let center = UNUserNotificationCenter.current() center.delegate = self center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in if let error = error { - print("🔔 Notification auth error:", error) + #logDebug("🔔 Notification auth error:", error) return } guard granted else { - print("🔔 Notification permission not granted") + #logDebug("🔔 Notification permission not granted") return } @@ -100,7 +110,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent NotificationCenter.default.post( name: .pushNotificationDeepLink, object: nil, - userInfo: ["url": urlString] + userInfo: [ + "url": urlString, + "deeplink_type": "push" + ] ) } } diff --git a/SseuDamApp/Sources/Application/LiveDependencies.swift b/SseuDamApp/Sources/Application/LiveDependencies.swift index 3db7066..7dc1305 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -40,6 +40,9 @@ public enum LiveDependencies { dependencies.profileUseCase = ProfileUseCase(repository: profileRepository) dependencies.versionUseCase = VersionUseCase(repository: versionRepository) + // Analytics + dependencies.analyticsUseCase = AnalyticsUseCase(repository: FirebaseAnalyticsRepository()) + // Travel dependencies.fetchTravelsUseCase = FetchTravelsUseCase(repository: travelRepository) dependencies.createTravelUseCase = CreateTravelUseCase(repository: travelRepository) diff --git a/SseuDamApp/Sources/Reducer/AppFeature.swift b/SseuDamApp/Sources/Reducer/AppFeature.swift index 082a6d3..f9d073c 100644 --- a/SseuDamApp/Sources/Reducer/AppFeature.swift +++ b/SseuDamApp/Sources/Reducer/AppFeature.swift @@ -17,6 +17,8 @@ import Data @Reducer struct AppFeature { + @Dependency(\.analyticsUseCase) var analyticsUseCase + // MARK: - State @ObservableState struct State: Equatable { @@ -274,7 +276,12 @@ extension AppFeature { case .setupPushNotificationObserver: return .run { send in for await notification in NotificationCenter.default.notifications(named: .pushNotificationDeepLink) { - if let urlString = notification.userInfo?["url"] as? String { + if let urlString = notification.userInfo?["url"] as? String, + let deeplinkType = notification.userInfo?["deeplink_type"] as? String { + + // Analytics 이벤트 전송 + analyticsUseCase.track(.deeplink(DeeplinkEventData(deeplink: urlString, type: deeplinkType))) + await send(.view(.handlePushNotificationDeepLink(urlString))) } } @@ -318,7 +325,8 @@ extension AppFeature { blendDuration: 0.1 ) ) - + + default: return .none } diff --git a/Tuist/Package.swift b/Tuist/Package.swift index f2ad15e..fb6cffd 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -10,8 +10,6 @@ let packageSettings = PackageSettings( "TCACoordinators": .framework, "Moya": .framework, "LogMacro": .framework, - "AppAuth": .framework, -// "GoogleSignIn": .framework ] ) @@ -26,6 +24,7 @@ let package = Package( .package(url: "https://github.com/supabase/supabase-swift.git", from: "2.37.0"), .package(url: "https://github.com/google/GoogleSignIn-iOS.git", from: "9.0.0"), .package(url: "https://github.com/Roy-wonji/LogMacro.git", from: "1.1.1"), - .package(url: "https://github.com/Moya/Moya.git", exact: "15.0.3") + .package(url: "https://github.com/Moya/Moya.git", exact: "15.0.3"), + .package(url: "https://github.com/firebase/firebase-ios-sdk.git", exact: "11.0.0") ] ) diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/ Project+Settings.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/ Project+Settings.swift index beee0c6..973220c 100644 --- a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/ Project+Settings.swift +++ b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/ Project+Settings.swift @@ -27,7 +27,7 @@ extension Settings { base: SettingsDictionary() .setProductName(Environment.appName) .setCFBundleDisplayName(Environment.appName) - .setMarketingVersion("1.0.0") + .setMarketingVersion("1.0.2") .setASAuthenticationServicesEnabled() .setCurrentProjectVersion("10") .setCodeSignIdentity() diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift index 56d43ba..52cef53 100644 --- a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift +++ b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift @@ -9,7 +9,7 @@ public enum Environment { public static let organizationTeamId = "N94CS4N6VR" // MARK: - Version Management - public static let mainAppVersion = "1.0.0" + public static let mainAppVersion = "1.0.2" public static let demoAppVersion = "0.1.0" // Demo app용 별도 버전 // MARK: - Platform diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Project+Helpers.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Project+Helpers.swift index 14e57ba..76d6a51 100644 --- a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Project+Helpers.swift +++ b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Project+Helpers.swift @@ -9,6 +9,7 @@ public extension Project { resources: ResourceFileElements? = nil, infoPlist: InfoPlist = .defaultSwiftUIApp, entitlements: ProjectDescription.Entitlements? = nil, + schemes: [Scheme] = [] ) -> Project { return Project( name: name, @@ -30,7 +31,8 @@ public extension Project { dependencies: dependencies, settings: .appMainSetting, ) - ] + ], + schemes: schemes ) } diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+SPM.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+SPM.swift index 42325bf..d48e582 100644 --- a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+SPM.swift +++ b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+SPM.swift @@ -17,4 +17,6 @@ public extension TargetDependency.SPM { static let GoogleSignIn: TargetDependency = .external(name: "GoogleSignIn") static let AppAuth: TargetDependency = .external(name: "AppAuth") static let LogMacro: TargetDependency = .external(name: "LogMacro") + static let FirebaseAnalytics = TargetDependency.external(name: "FirebaseAnalytics", condition: .none) + static let FirebaseCrashlytics = TargetDependency.external(name: "FirebaseCrashlytics", condition: .none) }