From e14c0cadab9eebbe51cdc290b66bccbd2a47067e Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 11 Dec 2025 20:23:59 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[feat]:=20Firebase=20Analytics=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adding Firebase Analytics SDK dependency - Creating Analytics manager and use case infrastructure - Implementing analytics tracking for various user actions (login, signup, travel management, deeplinks, etc.) - Updating app configuration to support Firebase - Version bump to 1.0.2 --- .mise.toml | 2 +- Data/Project.swift | 2 +- .../Analytics/FirebaseAnalyticsManager.swift | 72 +++++++++++++++++++ .../UseCase/Analytics/AnalyticsManaging.swift | 42 +++++++++++ .../UseCase/Analytics/AnalyticsUseCase.swift | 70 ++++++++++++++++++ .../Sources/Login/Reducer/LoginFeature.swift | 9 +++ Features/Main/Sources/MainCoordinator.swift | 10 ++- .../Sources/Reducer/BasicSettingFeature.swift | 3 + .../Reducer/MemberSettingFeature.swift | 3 + .../Sources/Reducer/TravelManageFeature.swift | 3 + .../Sources/Application/AppDelegate.swift | 13 +++- .../Application/LiveDependencies.swift | 5 ++ SseuDamApp/Sources/Reducer/AppFeature.swift | 20 +++++- Tuist/Package.swift | 4 +- .../ Project+Settings.swift | 2 +- .../Environment.swift | 2 +- .../Project+Helpers.swift | 4 +- .../TargetDependency+SPM.swift | 2 + 18 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 Data/Sources/Analytics/FirebaseAnalyticsManager.swift create mode 100644 Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift create mode 100644 Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift diff --git a/.mise.toml b/.mise.toml index 98066272..77a4b59f 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 89d00b89..30590830 100644 --- a/Data/Project.swift +++ b/Data/Project.swift @@ -8,7 +8,7 @@ let project = Project.makeFramework( .NetworkService, .SPM.Supabase, .SPM.GoogleSignIn, - .SPM.AppAuth + .SPM.FirebaseAnalytics ], hasTests: true, ) diff --git a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift new file mode 100644 index 00000000..412b93ed --- /dev/null +++ b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift @@ -0,0 +1,72 @@ +import Foundation +import FirebaseAnalytics +import Domain + +/// FirebaseAnalytics를 사용해 이벤트를 전송하는 Live 구현체. +public struct FirebaseAnalyticsManager: AnalyticsManaging { + public init() {} + + // MARK: - Deeplink / Expense + public func trackDeeplinkOpen(deeplink: String, type: String) { + Analytics.logEvent("deeplink_open", parameters: [ + "deeplink": deeplink, + "deeplink_type": type + ]) + } + + public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) { + Analytics.logEvent("expense_open_detail", parameters: [ + "travel_id": travelId, + "expense_id": expenseId, + "source": source + ]) + } + + public func trackLoginSuccess(socialType: String, isFirst: Bool?) { + var params: [String: Any] = ["social_type": socialType] + if let isFirst { params["is_first"] = isFirst } + print("🔥 [Analytics] Tracking login_success: \(params)") + Analytics.logEvent("login_success", parameters: params) + } + + public func trackSignupSuccess(socialType: String) { + let params = ["social_type": socialType] + print("🔥 [Analytics] Tracking signup_success: \(params)") + Analytics.logEvent("signup_success", parameters: params) + } + + // MARK: - Travel + public func trackTravelUpdate(_ travelId: String) { + Analytics.logEvent("travel_update", parameters: [ + "travel_id": travelId + ]) + } + + public func trackTravelDelete(_ travelId: String) { + Analytics.logEvent("travel_delete", parameters: [ + "travel_id": travelId + ]) + } + + public func trackTravelLeave(travelId: String, userId: String?) { + var params: [String: Any] = ["travel_id": travelId] + if let userId { params["user_id"] = userId } + Analytics.logEvent("travel_leave", parameters: params) + } + + public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) { + var params: [String: Any] = [ + "travel_id": travelId, + "member_id": memberId + ] + if let role { params["role"] = role } + Analytics.logEvent("travel_member_leave", parameters: params) + } + + public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) { + Analytics.logEvent("travel_owner_delegate", parameters: [ + "travel_id": travelId, + "new_owner_id": newOwnerId + ]) + } +} diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift b/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift new file mode 100644 index 00000000..bd8c4128 --- /dev/null +++ b/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift @@ -0,0 +1,42 @@ +import Foundation +import Dependencies + +/// Analytics 전송을 위한 프로토콜. 도메인/피처에서 의존성 주입으로 사용합니다. +public protocol AnalyticsManaging: Sendable { + // Deeplink / Expense + func trackDeeplinkOpen(deeplink: String, type: String) + func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) + func trackLoginSuccess(socialType: String, isFirst: Bool?) + func trackSignupSuccess(socialType: String) + + // Travel + func trackTravelUpdate(_ travelId: String) + func trackTravelDelete(_ travelId: String) + func trackTravelLeave(travelId: String, userId: String?) + func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) + func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) +} + +struct NoOpAnalyticsManager: AnalyticsManaging { + func trackDeeplinkOpen(deeplink: String, type: String) {} + func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) {} + func trackLoginSuccess(socialType: String, isFirst: Bool?) {} + func trackSignupSuccess(socialType: String) {} + func trackTravelUpdate(_ travelId: String) {} + func trackTravelDelete(_ travelId: String) {} + func trackTravelLeave(travelId: String, userId: String?) {} + func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) {} + func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) {} +} + +private enum AnalyticsManagerKey: DependencyKey { + static let liveValue: any AnalyticsManaging = NoOpAnalyticsManager() + static let testValue: any AnalyticsManaging = NoOpAnalyticsManager() +} + +public extension DependencyValues { + var analyticsManager: any AnalyticsManaging { + get { self[AnalyticsManagerKey.self] } + set { self[AnalyticsManagerKey.self] = newValue } + } +} diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift new file mode 100644 index 00000000..2b579719 --- /dev/null +++ b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift @@ -0,0 +1,70 @@ +import Foundation +import Dependencies + +public protocol AnalyticsUseCaseProtocol: Sendable { + func trackDeeplinkOpen(deeplink: String, type: String) + func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) + func trackLoginSuccess(socialType: String, isFirst: Bool?) + func trackSignupSuccess(socialType: String) + func trackTravelUpdate(_ travelId: String) + func trackTravelDelete(_ travelId: String) + func trackTravelLeave(travelId: String, userId: String?) + func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) + func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) +} + +public struct AnalyticsUseCase: AnalyticsUseCaseProtocol { + private let manager: any AnalyticsManaging + + public init(manager: any AnalyticsManaging) { + self.manager = manager + } + + public func trackDeeplinkOpen(deeplink: String, type: String) { + manager.trackDeeplinkOpen(deeplink: deeplink, type: type) + } + + public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) { + manager.trackExpenseOpenDetail(travelId: travelId, expenseId: expenseId, source: source) + } + + public func trackLoginSuccess(socialType: String, isFirst: Bool?) { + manager.trackLoginSuccess(socialType: socialType, isFirst: isFirst) + } + + public func trackSignupSuccess(socialType: String) { + manager.trackSignupSuccess(socialType: socialType) + } + + public func trackTravelUpdate(_ travelId: String) { + manager.trackTravelUpdate(travelId) + } + + public func trackTravelDelete(_ travelId: String) { + manager.trackTravelDelete(travelId) + } + + public func trackTravelLeave(travelId: String, userId: String?) { + manager.trackTravelLeave(travelId: travelId, userId: userId) + } + + public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) { + manager.trackTravelMemberLeave(travelId: travelId, memberId: memberId, role: role) + } + + public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) { + manager.trackTravelOwnerDelegate(travelId: travelId, newOwnerId: newOwnerId) + } +} + +private enum AnalyticsUseCaseKey: DependencyKey { + static let liveValue: AnalyticsUseCaseProtocol = AnalyticsUseCase(manager: NoOpAnalyticsManager()) + static let testValue: AnalyticsUseCaseProtocol = AnalyticsUseCase(manager: NoOpAnalyticsManager()) +} + +public extension DependencyValues { + var analyticsUseCase: AnalyticsUseCaseProtocol { + get { self[AnalyticsUseCaseKey.self] } + set { self[AnalyticsUseCaseKey.self] = newValue } + } +} diff --git a/Features/Login/Sources/Login/Reducer/LoginFeature.swift b/Features/Login/Sources/Login/Reducer/LoginFeature.swift index 130b25d5..3dc0d9e3 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.trackSignupSuccess(socialType: social) + analyticsUseCase.trackLoginSuccess(socialType: social, isFirst: true) + } else { + analyticsUseCase.trackLoginSuccess(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 45a20a79..5440ed73 100644 --- a/Features/Main/Sources/MainCoordinator.swift +++ b/Features/Main/Sources/MainCoordinator.swift @@ -34,6 +34,7 @@ public struct MainCoordinator { public enum DelegateAction { case presentLogin + case trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) } public var body: some ReducerOf { @@ -113,6 +114,9 @@ extension MainCoordinator { case .presentLogin: return .none + + case .trackExpenseOpenDetail: + return .none } } @@ -182,9 +186,13 @@ 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))))) + return .merge( + .send(.delegate(.trackExpenseOpenDetail(travelId: travelId, expenseId: expenseId, source: "deeplink"))), + .send(.router(.routeAction(id: routeIndex, action: .settlementCoordinator(.navigateToExpenseTab(expenseId))))) + ) } else if remainingComponents.count >= 1, remainingComponents[0] == "settlement" { #logDebug("📊 Navigating to settlement tab") diff --git a/Features/Travel/Sources/Reducer/BasicSettingFeature.swift b/Features/Travel/Sources/Reducer/BasicSettingFeature.swift index b8a1e43f..18d0b57a 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.trackTravelUpdate(updated.id) + return .merge( .send(.updated(state.travel)), .run { _ in diff --git a/Features/Travel/Sources/Reducer/MemberSettingFeature.swift b/Features/Travel/Sources/Reducer/MemberSettingFeature.swift index 9b69adb1..8b1be251 100644 --- a/Features/Travel/Sources/Reducer/MemberSettingFeature.swift +++ b/Features/Travel/Sources/Reducer/MemberSettingFeature.swift @@ -51,6 +51,7 @@ public struct MemberSettingFeature { @Dependency(\.delegateOwnerUseCase) var delegateOwnerUseCase @Dependency(\.deleteTravelMemberUseCase) var deleteTravelMemberUseCase + @Dependency(\.analyticsUseCase) var analyticsUseCase public var body: some Reducer { Reduce { state, action in @@ -73,6 +74,7 @@ public struct MemberSettingFeature { state.travel = updated state.members = updated.members state.ownerId = updated.ownerName + analyticsUseCase.trackTravelOwnerDelegate(travelId: updated.id, newOwnerId: updated.ownerName) return .send(.delegate(.needRefresh)) case .delegateOwnerResponse(.failure(let err)): @@ -98,6 +100,7 @@ public struct MemberSettingFeature { state.isSubmitting = false if let id = state.deletingMemberId { state.members.removeAll { $0.id == id } + analyticsUseCase.trackTravelMemberLeave(travelId: state.travel.id, memberId: id, role: nil) } state.deletingMemberId = nil return .send(.delegate(.needRefresh)) diff --git a/Features/Travel/Sources/Reducer/TravelManageFeature.swift b/Features/Travel/Sources/Reducer/TravelManageFeature.swift index b4e39ea0..92c54b33 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.trackTravelLeave(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.trackTravelDelete(state.travelId) return .send(.dismissRequested) case .deleteResponse(.failure(let err)): diff --git a/SseuDamApp/Sources/Application/AppDelegate.swift b/SseuDamApp/Sources/Application/AppDelegate.swift index baf96409..49020e55 100644 --- a/SseuDamApp/Sources/Application/AppDelegate.swift +++ b/SseuDamApp/Sources/Application/AppDelegate.swift @@ -9,6 +9,8 @@ import UIKit import UserNotifications import LogMacro import Data +import Firebase +import FirebaseAnalytics @MainActor @@ -19,6 +21,12 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil ) -> Bool { + #if DEBUG + // Enable Firebase Analytics verbose logging in debug builds without needing scheme args. + setenv("FIRAnalyticsDebugEnabled", "1", 1) + #endif + + FirebaseApp.configure() let center = UNUserNotificationCenter.current() center.delegate = self @@ -100,7 +108,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 9f4c7862..419ffdd6 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -40,6 +40,11 @@ public enum LiveDependencies { dependencies.profileUseCase = ProfileUseCase(repository: profileRepository) dependencies.versionUseCase = VersionUseCase(repository: versionRepository) + // Analytics + let analyticsManager = FirebaseAnalyticsManager() + dependencies.analyticsManager = analyticsManager + dependencies.analyticsUseCase = AnalyticsUseCase(manager: analyticsManager) + // 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 082a6d35..ebbb1e40 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.trackDeeplinkOpen(deeplink: urlString, type: deeplinkType) + await send(.view(.handlePushNotificationDeepLink(urlString))) } } @@ -318,7 +325,16 @@ extension AppFeature { blendDuration: 0.1 ) ) - + + case .main(.delegate(.trackExpenseOpenDetail(let travelId, let expenseId, let source))): + return .run { _ in + analyticsUseCase.trackExpenseOpenDetail( + travelId: travelId, + expenseId: expenseId, + source: source + ) + } + default: return .none } diff --git a/Tuist/Package.swift b/Tuist/Package.swift index f2ad15e4..21116ab9 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -10,7 +10,6 @@ let packageSettings = PackageSettings( "TCACoordinators": .framework, "Moya": .framework, "LogMacro": .framework, - "AppAuth": .framework, // "GoogleSignIn": .framework ] @@ -26,6 +25,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 beee0c6c..973220c7 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 56d43baf..52cef535 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 14e57ba3..76d6a510 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 42325bfd..d48e582b 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) } From f386c877a0e8f1b0565d706f1de3af43e38f5725 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 11 Dec 2025 20:25:01 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[chore]:=20info.plist=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 72ac59bd..3a2d1d4a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ DerivedData/ !default.perspectivev3 Config/ .env - +GoogleService-Info.plist ## Obj-C/Swift specific *.hmap From da441c22713a69296b28aa6258f9d11ea0a275bb Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 11 Dec 2025 20:44:03 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[refactor]:=20Analytics=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EA=B9=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Analytics/FirebaseAnalyticsManager.swift | 28 +++-- .../UseCase/Analytics/AnalyticsManaging.swift | 2 +- .../UseCase/Analytics/AnalyticsUseCase.swift | 112 +++++++++--------- .../Sources/Application/AppDelegate.swift | 9 +- 4 files changed, 80 insertions(+), 71 deletions(-) diff --git a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift index 412b93ed..4d8c1f8a 100644 --- a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift +++ b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift @@ -1,11 +1,18 @@ import Foundation import FirebaseAnalytics import Domain +import LogMacro /// FirebaseAnalytics를 사용해 이벤트를 전송하는 Live 구현체. public struct FirebaseAnalyticsManager: AnalyticsManaging { - public init() {} - + public init() { + Analytics.logEvent("app_analytics_initialized", parameters: [ + "timestamp": Date().timeIntervalSince1970, + "version": "1.0" + ]) + #logDebug("🔥 [Analytics] Test event app_analytics_initialized sent") + } + // MARK: - Deeplink / Expense public func trackDeeplinkOpen(deeplink: String, type: String) { Analytics.logEvent("deeplink_open", parameters: [ @@ -13,7 +20,7 @@ public struct FirebaseAnalyticsManager: AnalyticsManaging { "deeplink_type": type ]) } - + public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) { Analytics.logEvent("expense_open_detail", parameters: [ "travel_id": travelId, @@ -25,35 +32,36 @@ public struct FirebaseAnalyticsManager: AnalyticsManaging { public func trackLoginSuccess(socialType: String, isFirst: Bool?) { var params: [String: Any] = ["social_type": socialType] if let isFirst { params["is_first"] = isFirst } - print("🔥 [Analytics] Tracking login_success: \(params)") + #logDebug("🔥 [Analytics] Tracking login_success: \(params)") Analytics.logEvent("login_success", parameters: params) + #logDebug("🔥 [Analytics] login_success event sent to Firebase") } public func trackSignupSuccess(socialType: String) { let params = ["social_type": socialType] - print("🔥 [Analytics] Tracking signup_success: \(params)") + #logDebug("🔥 [Analytics] Tracking signup_success: \(params)") Analytics.logEvent("signup_success", parameters: params) } - + // MARK: - Travel public func trackTravelUpdate(_ travelId: String) { Analytics.logEvent("travel_update", parameters: [ "travel_id": travelId ]) } - + public func trackTravelDelete(_ travelId: String) { Analytics.logEvent("travel_delete", parameters: [ "travel_id": travelId ]) } - + public func trackTravelLeave(travelId: String, userId: String?) { var params: [String: Any] = ["travel_id": travelId] if let userId { params["user_id"] = userId } Analytics.logEvent("travel_leave", parameters: params) } - + public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) { var params: [String: Any] = [ "travel_id": travelId, @@ -62,7 +70,7 @@ public struct FirebaseAnalyticsManager: AnalyticsManaging { if let role { params["role"] = role } Analytics.logEvent("travel_member_leave", parameters: params) } - + public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) { Analytics.logEvent("travel_owner_delegate", parameters: [ "travel_id": travelId, diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift b/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift index bd8c4128..3e2ccdda 100644 --- a/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift +++ b/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift @@ -8,7 +8,7 @@ public protocol AnalyticsManaging: Sendable { func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) func trackLoginSuccess(socialType: String, isFirst: Bool?) func trackSignupSuccess(socialType: String) - + // Travel func trackTravelUpdate(_ travelId: String) func trackTravelDelete(_ travelId: String) diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift index 2b579719..04862a0d 100644 --- a/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift +++ b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift @@ -2,69 +2,69 @@ import Foundation import Dependencies public protocol AnalyticsUseCaseProtocol: Sendable { - func trackDeeplinkOpen(deeplink: String, type: String) - func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) - func trackLoginSuccess(socialType: String, isFirst: Bool?) - func trackSignupSuccess(socialType: String) - func trackTravelUpdate(_ travelId: String) - func trackTravelDelete(_ travelId: String) - func trackTravelLeave(travelId: String, userId: String?) - func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) - func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) + func trackDeeplinkOpen(deeplink: String, type: String) + func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) + func trackLoginSuccess(socialType: String, isFirst: Bool?) + func trackSignupSuccess(socialType: String) + func trackTravelUpdate(_ travelId: String) + func trackTravelDelete(_ travelId: String) + func trackTravelLeave(travelId: String, userId: String?) + func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) + func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) } public struct AnalyticsUseCase: AnalyticsUseCaseProtocol { - private let manager: any AnalyticsManaging - - public init(manager: any AnalyticsManaging) { - self.manager = manager - } - - public func trackDeeplinkOpen(deeplink: String, type: String) { - manager.trackDeeplinkOpen(deeplink: deeplink, type: type) - } - - public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) { - manager.trackExpenseOpenDetail(travelId: travelId, expenseId: expenseId, source: source) - } - - public func trackLoginSuccess(socialType: String, isFirst: Bool?) { - manager.trackLoginSuccess(socialType: socialType, isFirst: isFirst) - } - - public func trackSignupSuccess(socialType: String) { - manager.trackSignupSuccess(socialType: socialType) - } - - public func trackTravelUpdate(_ travelId: String) { - manager.trackTravelUpdate(travelId) - } - - public func trackTravelDelete(_ travelId: String) { - manager.trackTravelDelete(travelId) - } - - public func trackTravelLeave(travelId: String, userId: String?) { - manager.trackTravelLeave(travelId: travelId, userId: userId) - } - - public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) { - manager.trackTravelMemberLeave(travelId: travelId, memberId: memberId, role: role) - } - - public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) { - manager.trackTravelOwnerDelegate(travelId: travelId, newOwnerId: newOwnerId) - } + private let manager: any AnalyticsManaging + + public init(manager: any AnalyticsManaging) { + self.manager = manager + } + + public func trackDeeplinkOpen(deeplink: String, type: String) { + manager.trackDeeplinkOpen(deeplink: deeplink, type: type) + } + + public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) { + manager.trackExpenseOpenDetail(travelId: travelId, expenseId: expenseId, source: source) + } + + public func trackLoginSuccess(socialType: String, isFirst: Bool?) { + manager.trackLoginSuccess(socialType: socialType, isFirst: isFirst) + } + + public func trackSignupSuccess(socialType: String) { + manager.trackSignupSuccess(socialType: socialType) + } + + public func trackTravelUpdate(_ travelId: String) { + manager.trackTravelUpdate(travelId) + } + + public func trackTravelDelete(_ travelId: String) { + manager.trackTravelDelete(travelId) + } + + public func trackTravelLeave(travelId: String, userId: String?) { + manager.trackTravelLeave(travelId: travelId, userId: userId) + } + + public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) { + manager.trackTravelMemberLeave(travelId: travelId, memberId: memberId, role: role) + } + + public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) { + manager.trackTravelOwnerDelegate(travelId: travelId, newOwnerId: newOwnerId) + } } private enum AnalyticsUseCaseKey: DependencyKey { - static let liveValue: AnalyticsUseCaseProtocol = AnalyticsUseCase(manager: NoOpAnalyticsManager()) - static let testValue: AnalyticsUseCaseProtocol = AnalyticsUseCase(manager: NoOpAnalyticsManager()) + static let liveValue: AnalyticsUseCaseProtocol = AnalyticsUseCase(manager: NoOpAnalyticsManager()) + static let testValue: AnalyticsUseCaseProtocol = AnalyticsUseCase(manager: NoOpAnalyticsManager()) } public extension DependencyValues { - var analyticsUseCase: AnalyticsUseCaseProtocol { - get { self[AnalyticsUseCaseKey.self] } - set { self[AnalyticsUseCaseKey.self] = newValue } - } + var analyticsUseCase: AnalyticsUseCaseProtocol { + get { self[AnalyticsUseCaseKey.self] } + set { self[AnalyticsUseCaseKey.self] = newValue } + } } diff --git a/SseuDamApp/Sources/Application/AppDelegate.swift b/SseuDamApp/Sources/Application/AppDelegate.swift index 49020e55..2a213ed4 100644 --- a/SseuDamApp/Sources/Application/AppDelegate.swift +++ b/SseuDamApp/Sources/Application/AppDelegate.swift @@ -13,6 +13,7 @@ import Firebase import FirebaseAnalytics + @MainActor final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { @@ -22,8 +23,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent ) -> Bool { #if DEBUG - // Enable Firebase Analytics verbose logging in debug builds without needing scheme args. - setenv("FIRAnalyticsDebugEnabled", "1", 1) + setenv("FIRAnalyticsDebugEnabled", "1", 1) + setenv("FIRDebugEnabled", "1", 1) #endif FirebaseApp.configure() @@ -32,12 +33,12 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent 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 } From e05f107e8634bae3b9e28cb15bb65df8688f5b48 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 11 Dec 2025 20:47:43 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[chore]:=20Firebase=20Crashlytics=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Data/Project.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Data/Project.swift b/Data/Project.swift index 30590830..6ea662e1 100644 --- a/Data/Project.swift +++ b/Data/Project.swift @@ -8,7 +8,8 @@ let project = Project.makeFramework( .NetworkService, .SPM.Supabase, .SPM.GoogleSignIn, - .SPM.FirebaseAnalytics + .SPM.FirebaseAnalytics, + .SPM.FirebaseCrashlytics ], hasTests: true, ) From 8fec7a93e98891d57d3fbfb5b654b6eca10ed35b Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 12 Dec 2025 00:58:53 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[fix]:=20Firebase=20Analytics=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Data/Project.swift | 6 +++++- .../Analytics/FirebaseAnalyticsManager.swift | 2 +- .../UseCase/Analytics/AnalyticsManaging.swift | 21 ++++++++++--------- Tuist/Package.swift | 4 ++++ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Data/Project.swift b/Data/Project.swift index 6ea662e1..f95e9d78 100644 --- a/Data/Project.swift +++ b/Data/Project.swift @@ -9,7 +9,11 @@ let project = Project.makeFramework( .SPM.Supabase, .SPM.GoogleSignIn, .SPM.FirebaseAnalytics, - .SPM.FirebaseCrashlytics + .SPM.FirebaseCrashlytics, ], hasTests: true, + settings: .settings( + base: SettingsDictionary() + .otherLinkerFlags(["-all_load"]), + ) ) diff --git a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift index 4d8c1f8a..33850588 100644 --- a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift +++ b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift @@ -4,7 +4,7 @@ import Domain import LogMacro /// FirebaseAnalytics를 사용해 이벤트를 전송하는 Live 구현체. -public struct FirebaseAnalyticsManager: AnalyticsManaging { +public class FirebaseAnalyticsManager: AnalyticsManaging, @unchecked Sendable { public init() { Analytics.logEvent("app_analytics_initialized", parameters: [ "timestamp": Date().timeIntervalSince1970, diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift b/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift index 3e2ccdda..c961aa50 100644 --- a/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift +++ b/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift @@ -17,16 +17,17 @@ public protocol AnalyticsManaging: Sendable { func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) } -struct NoOpAnalyticsManager: AnalyticsManaging { - func trackDeeplinkOpen(deeplink: String, type: String) {} - func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) {} - func trackLoginSuccess(socialType: String, isFirst: Bool?) {} - func trackSignupSuccess(socialType: String) {} - func trackTravelUpdate(_ travelId: String) {} - func trackTravelDelete(_ travelId: String) {} - func trackTravelLeave(travelId: String, userId: String?) {} - func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) {} - func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) {} +public struct NoOpAnalyticsManager: AnalyticsManaging { + public init() {} + public func trackDeeplinkOpen(deeplink: String, type: String) {} + public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) {} + public func trackLoginSuccess(socialType: String, isFirst: Bool?) {} + public func trackSignupSuccess(socialType: String) {} + public func trackTravelUpdate(_ travelId: String) {} + public func trackTravelDelete(_ travelId: String) {} + public func trackTravelLeave(travelId: String, userId: String?) {} + public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) {} + public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) {} } private enum AnalyticsManagerKey: DependencyKey { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 21116ab9..154cc6e2 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -10,6 +10,10 @@ let packageSettings = PackageSettings( "TCACoordinators": .framework, "Moya": .framework, "LogMacro": .framework, + "FirebaseCore": .staticLibrary, + "FirebaseFirestore": .staticLibrary, + "FirebaseAnalytics": .staticLibrary, + "FirebaseCrashlytics": .staticLibrary, // "GoogleSignIn": .framework ] From 7c292d0097ecd6b5285a5a70fcf6c99594989f38 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 12 Dec 2025 01:19:16 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[feat]:=20Firebase=20Analytics=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Analytics/FirebaseAnalyticsManager.swift | 133 +++++++++++++++--- .../UseCase/Analytics/AnalyticsManaging.swift | 14 +- .../Sources/Application/AppDelegate.swift | 1 + 3 files changed, 130 insertions(+), 18 deletions(-) diff --git a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift index 33850588..4a297884 100644 --- a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift +++ b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift @@ -6,62 +6,73 @@ import LogMacro /// FirebaseAnalytics를 사용해 이벤트를 전송하는 Live 구현체. public class FirebaseAnalyticsManager: AnalyticsManaging, @unchecked Sendable { public init() { + #logDebug("🔥 [Analytics] ===== FIREBASE ANALYTICS MANAGER INITIALIZED =====") + #logDebug("🔥 [Analytics] Sending app_analytics_initialized event...") + Analytics.logEvent("app_analytics_initialized", parameters: [ "timestamp": Date().timeIntervalSince1970, "version": "1.0" ]) - #logDebug("🔥 [Analytics] Test event app_analytics_initialized sent") } - + // MARK: - Deeplink / Expense public func trackDeeplinkOpen(deeplink: String, type: String) { - Analytics.logEvent("deeplink_open", parameters: [ + let parameters: [String: Any] = [ "deeplink": deeplink, "deeplink_type": type - ]) + ] + + #logDebug("🔥 [Analytics] Parameters: \(parameters)") + + Analytics.logEvent("deeplink_open", parameters: parameters) + + #logDebug("🔥 [Analytics] ✅ deeplink_open event sent to Firebase") } - + public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) { + #logDebug("🔥 [Analytics] Parameters: travel_id=\(travelId), expense_id=\(expenseId), source=\(source)") + Analytics.logEvent("expense_open_detail", parameters: [ "travel_id": travelId, "expense_id": expenseId, "source": source ]) } - + public func trackLoginSuccess(socialType: String, isFirst: Bool?) { var params: [String: Any] = ["social_type": socialType] if let isFirst { params["is_first"] = isFirst } - #logDebug("🔥 [Analytics] Tracking login_success: \(params)") + #logDebug("🔥 [Analytics] Parameters: \(params)") + Analytics.logEvent("login_success", parameters: params) - #logDebug("🔥 [Analytics] login_success event sent to Firebase") } - + public func trackSignupSuccess(socialType: String) { let params = ["social_type": socialType] - #logDebug("🔥 [Analytics] Tracking signup_success: \(params)") + #logDebug("🔥 [Analytics] Parameters: \(params)") + Analytics.logEvent("signup_success", parameters: params) } - + // MARK: - Travel public func trackTravelUpdate(_ travelId: String) { Analytics.logEvent("travel_update", parameters: [ "travel_id": travelId ]) } - + public func trackTravelDelete(_ travelId: String) { Analytics.logEvent("travel_delete", parameters: [ "travel_id": travelId ]) } - + public func trackTravelLeave(travelId: String, userId: String?) { var params: [String: Any] = ["travel_id": travelId] if let userId { params["user_id"] = userId } Analytics.logEvent("travel_leave", parameters: params) } - + public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) { var params: [String: Any] = [ "travel_id": travelId, @@ -70,11 +81,99 @@ public class FirebaseAnalyticsManager: AnalyticsManaging, @unchecked Sendable { if let role { params["role"] = role } Analytics.logEvent("travel_member_leave", parameters: params) } - + public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) { - Analytics.logEvent("travel_owner_delegate", parameters: [ + let parameters: [String: Any] = [ "travel_id": travelId, "new_owner_id": newOwnerId - ]) + ] + + #logDebug("🔥 [Analytics] Parameters: \(parameters)") + + Analytics.logEvent("travel_owner_delegate", parameters: parameters) + } + + // MARK: - Additional Events from CSV + /// 지출 화면 진입 시 (expense_view) + public func trackExpenseView(travelId: String, tab: String, expenseDate: String) { + let parameters: [String: Any] = [ + "travel_id": travelId, + "tab": tab, + "expense_date": expenseDate + ] + #logDebug("🔥 [Analytics] Parameters: \(parameters)") + + Analytics.logEvent("expense_view", parameters: parameters) + } + + /// 지출 생성 성공 시 (expense_create_success) + public func trackExpenseCreateSuccess( + travelId: String, + expenseId: String, + amount: Double, + currency: String, + category: String, + payerId: String + ) { + let parameters: [String: Any] = [ + "travel_id": travelId, + "expense_id": expenseId, + "amount": amount, + "currency": currency, + "category": category, + "payer_id": payerId + ] + #logDebug("🔥 [Analytics] Parameters: \(parameters)") + + Analytics.logEvent("expense_create_success", parameters: parameters) + } + + /// 지출 생성 실패 시 (expense_create_failure) + public func trackExpenseCreateFailure( + travelId: String, + amount: Double, + currency: String, + category: String, + payerId: String, + errorCode: String + ) { + let parameters: [String: Any] = [ + "travel_id": travelId, + "amount": amount, + "currency": currency, + "category": category, + "payer_id": payerId, + "error_code": errorCode + ] + #logDebug("🔥 [Analytics] Parameters: \(parameters)") + + Analytics.logEvent("expense_create_failure", parameters: parameters) + } + + /// 지출 수정 성공 시 (expense_update) + public func trackExpenseUpdate(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) { + let parameters: [String: Any] = [ + "travel_id": travelId, + "expense_id": expenseId, + "amount": amount, + "currency": currency, + "category": category, + "payer_id": payerId + ] + #logDebug("🔥 [Analytics] Parameters: \(parameters)") + + Analytics.logEvent("expense_update", parameters: parameters) + } + + /// 지출 삭제 성공 시 (expense_delete) + public func trackExpenseDelete(travelId: String, expenseId: String, source: String) { + let parameters: [String: Any] = [ + "travel_id": travelId, + "expense_id": expenseId, + "source": source + ] + #logDebug("🔥 [Analytics] Parameters: \(parameters)") + + Analytics.logEvent("expense_delete", parameters: parameters) } } diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift b/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift index c961aa50..baef23e5 100644 --- a/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift +++ b/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift @@ -8,13 +8,20 @@ public protocol AnalyticsManaging: Sendable { func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) func trackLoginSuccess(socialType: String, isFirst: Bool?) func trackSignupSuccess(socialType: String) - + // Travel func trackTravelUpdate(_ travelId: String) func trackTravelDelete(_ travelId: String) func trackTravelLeave(travelId: String, userId: String?) func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) + + // Additional Events from CSV + func trackExpenseView(travelId: String, tab: String, expenseDate: String) + func trackExpenseCreateSuccess(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) + func trackExpenseCreateFailure(travelId: String, amount: Double, currency: String, category: String, payerId: String, errorCode: String) + func trackExpenseUpdate(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) + func trackExpenseDelete(travelId: String, expenseId: String, source: String) } public struct NoOpAnalyticsManager: AnalyticsManaging { @@ -28,6 +35,11 @@ public struct NoOpAnalyticsManager: AnalyticsManaging { public func trackTravelLeave(travelId: String, userId: String?) {} public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) {} public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) {} + public func trackExpenseView(travelId: String, tab: String, expenseDate: String) {} + public func trackExpenseCreateSuccess(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) {} + public func trackExpenseCreateFailure(travelId: String, amount: Double, currency: String, category: String, payerId: String, errorCode: String) {} + public func trackExpenseUpdate(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) {} + public func trackExpenseDelete(travelId: String, expenseId: String, source: String) {} } private enum AnalyticsManagerKey: DependencyKey { diff --git a/SseuDamApp/Sources/Application/AppDelegate.swift b/SseuDamApp/Sources/Application/AppDelegate.swift index 2a213ed4..367278a8 100644 --- a/SseuDamApp/Sources/Application/AppDelegate.swift +++ b/SseuDamApp/Sources/Application/AppDelegate.swift @@ -28,6 +28,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent #endif FirebaseApp.configure() + Analytics.setAnalyticsCollectionEnabled(true) let center = UNUserNotificationCenter.current() center.delegate = self From 8c2fe413f97d5677e91bf1910b291d1553d31d6f Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 12 Dec 2025 09:53:39 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[feat]:=20Analytics=20=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B2=98=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Analytics/FirebaseAnalyticsManager.swift | 196 +++++------------- .../Kakao/KakaoOAuthRepository.swift | 2 +- .../Entity/Analytics/AnalyticsEvent.swift | 9 + .../Entity/Analytics/AuthEventData.swift | 18 ++ .../Entity/Analytics/DeeplinkEventData.swift | 12 ++ .../Entity/Analytics/ExpenseEventData.swift | 49 +++++ .../Entity/Analytics/TravelEventData.swift | 33 +++ .../AnalyticsRepositoryProtocol.swift | 6 + .../Analytics/MockAnalyticsRepository.swift | 94 +++++++++ .../UseCase/Analytics/AnalyticsManaging.swift | 55 ----- .../UseCase/Analytics/AnalyticsUseCase.swift | 83 ++------ .../Analytics/AnalyticsUseCaseProtocol.swift | 12 ++ .../Sources/Login/Reducer/LoginFeature.swift | 6 +- Features/Main/Sources/MainCoordinator.swift | 10 +- .../Sources/Reducer/BasicSettingFeature.swift | 2 +- .../Reducer/MemberSettingFeature.swift | 4 +- .../Sources/Reducer/TravelManageFeature.swift | 4 +- .../Application/LiveDependencies.swift | 4 +- SseuDamApp/Sources/Reducer/AppFeature.swift | 10 +- Tuist/Package.swift | 5 - 20 files changed, 318 insertions(+), 296 deletions(-) create mode 100644 Domain/Sources/Entity/Analytics/AnalyticsEvent.swift create mode 100644 Domain/Sources/Entity/Analytics/AuthEventData.swift create mode 100644 Domain/Sources/Entity/Analytics/DeeplinkEventData.swift create mode 100644 Domain/Sources/Entity/Analytics/ExpenseEventData.swift create mode 100644 Domain/Sources/Entity/Analytics/TravelEventData.swift create mode 100644 Domain/Sources/Repository/Analytics/AnalyticsRepositoryProtocol.swift create mode 100644 Domain/Sources/Repository/Analytics/MockAnalyticsRepository.swift delete mode 100644 Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift create mode 100644 Domain/Sources/UseCase/Analytics/AnalyticsUseCaseProtocol.swift diff --git a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift index 4a297884..16693436 100644 --- a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift +++ b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift @@ -3,10 +3,11 @@ import FirebaseAnalytics import Domain import LogMacro -/// FirebaseAnalytics를 사용해 이벤트를 전송하는 Live 구현체. -public class FirebaseAnalyticsManager: AnalyticsManaging, @unchecked Sendable { +/// FirebaseAnalytics를 사용한 Analytics Repository 구현체 +public class FirebaseAnalyticsRepository: AnalyticsRepositoryProtocol, @unchecked Sendable { + public init() { - #logDebug("🔥 [Analytics] ===== FIREBASE ANALYTICS MANAGER INITIALIZED =====") + #logDebug("🔥 [Analytics] ===== FIREBASE ANALYTICS REPOSITORY INITIALIZED =====") #logDebug("🔥 [Analytics] Sending app_analytics_initialized event...") Analytics.logEvent("app_analytics_initialized", parameters: [ @@ -15,165 +16,70 @@ public class FirebaseAnalyticsManager: AnalyticsManaging, @unchecked Sendable { ]) } - // MARK: - Deeplink / Expense - public func trackDeeplinkOpen(deeplink: String, type: String) { - let parameters: [String: Any] = [ - "deeplink": deeplink, - "deeplink_type": type - ] - - #logDebug("🔥 [Analytics] Parameters: \(parameters)") - - Analytics.logEvent("deeplink_open", parameters: parameters) - - #logDebug("🔥 [Analytics] ✅ deeplink_open event sent to Firebase") + 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) + } } - public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) { - #logDebug("🔥 [Analytics] Parameters: travel_id=\(travelId), expense_id=\(expenseId), source=\(source)") + // MARK: - Private Event Handlers - Analytics.logEvent("expense_open_detail", parameters: [ - "travel_id": travelId, - "expense_id": expenseId, - "source": source - ]) - } + 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 + } - public func trackLoginSuccess(socialType: String, isFirst: Bool?) { - var params: [String: Any] = ["social_type": socialType] - if let isFirst { params["is_first"] = isFirst } #logDebug("🔥 [Analytics] Parameters: \(params)") - - Analytics.logEvent("login_success", parameters: params) - } - - public func trackSignupSuccess(socialType: String) { - let params = ["social_type": socialType] - #logDebug("🔥 [Analytics] Parameters: \(params)") - - Analytics.logEvent("signup_success", parameters: params) - } - - // MARK: - Travel - public func trackTravelUpdate(_ travelId: String) { - Analytics.logEvent("travel_update", parameters: [ - "travel_id": travelId - ]) - } - - public func trackTravelDelete(_ travelId: String) { - Analytics.logEvent("travel_delete", parameters: [ - "travel_id": travelId - ]) - } - - public func trackTravelLeave(travelId: String, userId: String?) { - var params: [String: Any] = ["travel_id": travelId] - if let userId { params["user_id"] = userId } - Analytics.logEvent("travel_leave", parameters: params) - } - - public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) { - var params: [String: Any] = [ - "travel_id": travelId, - "member_id": memberId - ] - if let role { params["role"] = role } - Analytics.logEvent("travel_member_leave", parameters: params) + Analytics.logEvent(eventType.rawValue, parameters: params) } - public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) { + private func sendDeeplinkEvent(_ data: DeeplinkEventData) async { let parameters: [String: Any] = [ - "travel_id": travelId, - "new_owner_id": newOwnerId + "deeplink": data.deeplink, + "deeplink_type": data.type ] #logDebug("🔥 [Analytics] Parameters: \(parameters)") - - Analytics.logEvent("travel_owner_delegate", parameters: parameters) - } - - // MARK: - Additional Events from CSV - /// 지출 화면 진입 시 (expense_view) - public func trackExpenseView(travelId: String, tab: String, expenseDate: String) { - let parameters: [String: Any] = [ - "travel_id": travelId, - "tab": tab, - "expense_date": expenseDate - ] - #logDebug("🔥 [Analytics] Parameters: \(parameters)") - - Analytics.logEvent("expense_view", parameters: parameters) + Analytics.logEvent("deeplink_open", parameters: parameters) + #logDebug("🔥 [Analytics] ✅ deeplink_open event sent to Firebase") } - /// 지출 생성 성공 시 (expense_create_success) - public func trackExpenseCreateSuccess( - travelId: String, - expenseId: String, - amount: Double, - currency: String, - category: String, - payerId: String - ) { - let parameters: [String: Any] = [ - "travel_id": travelId, - "expense_id": expenseId, - "amount": amount, - "currency": currency, - "category": category, - "payer_id": payerId - ] - #logDebug("🔥 [Analytics] Parameters: \(parameters)") + private func sendTravelEvent(_ eventType: TravelEventType, _ data: TravelEventData) async { + var params: [String: Any] = ["travel_id": data.travelId] - Analytics.logEvent("expense_create_success", parameters: parameters) - } + // 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 } - /// 지출 생성 실패 시 (expense_create_failure) - public func trackExpenseCreateFailure( - travelId: String, - amount: Double, - currency: String, - category: String, - payerId: String, - errorCode: String - ) { - let parameters: [String: Any] = [ - "travel_id": travelId, - "amount": amount, - "currency": currency, - "category": category, - "payer_id": payerId, - "error_code": errorCode - ] - #logDebug("🔥 [Analytics] Parameters: \(parameters)") - - Analytics.logEvent("expense_create_failure", parameters: parameters) + #logDebug("🔥 [Analytics] Parameters: \(params)") + Analytics.logEvent(eventType.rawValue, parameters: params) } - /// 지출 수정 성공 시 (expense_update) - public func trackExpenseUpdate(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) { - let parameters: [String: Any] = [ - "travel_id": travelId, - "expense_id": expenseId, - "amount": amount, - "currency": currency, - "category": category, - "payer_id": payerId - ] - #logDebug("🔥 [Analytics] Parameters: \(parameters)") + private func sendExpenseEvent(_ eventType: ExpenseEventType, _ data: ExpenseEventData) async { + var params: [String: Any] = ["travel_id": data.travelId] - Analytics.logEvent("expense_update", parameters: parameters) - } - - /// 지출 삭제 성공 시 (expense_delete) - public func trackExpenseDelete(travelId: String, expenseId: String, source: String) { - let parameters: [String: Any] = [ - "travel_id": travelId, - "expense_id": expenseId, - "source": source - ] - #logDebug("🔥 [Analytics] Parameters: \(parameters)") + // 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 } - Analytics.logEvent("expense_delete", parameters: parameters) + #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 fdafe71a..f1a8aae0 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 00000000..67c6afc9 --- /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 00000000..2e4fe2cd --- /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 00000000..cff7bcef --- /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 00000000..4f41b271 --- /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 00000000..1d726931 --- /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 00000000..5374ba10 --- /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 00000000..1d8a9887 --- /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/AnalyticsManaging.swift b/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift deleted file mode 100644 index baef23e5..00000000 --- a/Domain/Sources/UseCase/Analytics/AnalyticsManaging.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import Dependencies - -/// Analytics 전송을 위한 프로토콜. 도메인/피처에서 의존성 주입으로 사용합니다. -public protocol AnalyticsManaging: Sendable { - // Deeplink / Expense - func trackDeeplinkOpen(deeplink: String, type: String) - func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) - func trackLoginSuccess(socialType: String, isFirst: Bool?) - func trackSignupSuccess(socialType: String) - - // Travel - func trackTravelUpdate(_ travelId: String) - func trackTravelDelete(_ travelId: String) - func trackTravelLeave(travelId: String, userId: String?) - func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) - func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) - - // Additional Events from CSV - func trackExpenseView(travelId: String, tab: String, expenseDate: String) - func trackExpenseCreateSuccess(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) - func trackExpenseCreateFailure(travelId: String, amount: Double, currency: String, category: String, payerId: String, errorCode: String) - func trackExpenseUpdate(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) - func trackExpenseDelete(travelId: String, expenseId: String, source: String) -} - -public struct NoOpAnalyticsManager: AnalyticsManaging { - public init() {} - public func trackDeeplinkOpen(deeplink: String, type: String) {} - public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) {} - public func trackLoginSuccess(socialType: String, isFirst: Bool?) {} - public func trackSignupSuccess(socialType: String) {} - public func trackTravelUpdate(_ travelId: String) {} - public func trackTravelDelete(_ travelId: String) {} - public func trackTravelLeave(travelId: String, userId: String?) {} - public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) {} - public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) {} - public func trackExpenseView(travelId: String, tab: String, expenseDate: String) {} - public func trackExpenseCreateSuccess(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) {} - public func trackExpenseCreateFailure(travelId: String, amount: Double, currency: String, category: String, payerId: String, errorCode: String) {} - public func trackExpenseUpdate(travelId: String, expenseId: String, amount: Double, currency: String, category: String, payerId: String) {} - public func trackExpenseDelete(travelId: String, expenseId: String, source: String) {} -} - -private enum AnalyticsManagerKey: DependencyKey { - static let liveValue: any AnalyticsManaging = NoOpAnalyticsManager() - static let testValue: any AnalyticsManaging = NoOpAnalyticsManager() -} - -public extension DependencyValues { - var analyticsManager: any AnalyticsManaging { - get { self[AnalyticsManagerKey.self] } - set { self[AnalyticsManagerKey.self] = newValue } - } -} diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift index 04862a0d..abf9bee6 100644 --- a/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift +++ b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift @@ -1,70 +1,31 @@ import Foundation -import Dependencies +import ComposableArchitecture -public protocol AnalyticsUseCaseProtocol: Sendable { - func trackDeeplinkOpen(deeplink: String, type: String) - func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) - func trackLoginSuccess(socialType: String, isFirst: Bool?) - func trackSignupSuccess(socialType: String) - func trackTravelUpdate(_ travelId: String) - func trackTravelDelete(_ travelId: String) - func trackTravelLeave(travelId: String, userId: String?) - func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) - func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) -} +/// Analytics UseCase 구현체 +public struct AnalyticsUseCase: AnalyticsUseCaseProtocol, Sendable { + private let repository: any AnalyticsRepositoryProtocol + + public init(repository: any AnalyticsRepositoryProtocol) { + self.repository = repository + } -public struct AnalyticsUseCase: AnalyticsUseCaseProtocol { - private let manager: any AnalyticsManaging - - public init(manager: any AnalyticsManaging) { - self.manager = manager - } - - public func trackDeeplinkOpen(deeplink: String, type: String) { - manager.trackDeeplinkOpen(deeplink: deeplink, type: type) - } - - public func trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) { - manager.trackExpenseOpenDetail(travelId: travelId, expenseId: expenseId, source: source) - } - - public func trackLoginSuccess(socialType: String, isFirst: Bool?) { - manager.trackLoginSuccess(socialType: socialType, isFirst: isFirst) - } - - public func trackSignupSuccess(socialType: String) { - manager.trackSignupSuccess(socialType: socialType) - } - - public func trackTravelUpdate(_ travelId: String) { - manager.trackTravelUpdate(travelId) - } - - public func trackTravelDelete(_ travelId: String) { - manager.trackTravelDelete(travelId) - } - - public func trackTravelLeave(travelId: String, userId: String?) { - manager.trackTravelLeave(travelId: travelId, userId: userId) - } - - public func trackTravelMemberLeave(travelId: String, memberId: String, role: String?) { - manager.trackTravelMemberLeave(travelId: travelId, memberId: memberId, role: role) - } - - public func trackTravelOwnerDelegate(travelId: String, newOwnerId: String) { - manager.trackTravelOwnerDelegate(travelId: travelId, newOwnerId: newOwnerId) - } + public func track(_ event: AnalyticsEvent) { + Task { + await repository.sendEvent(event) + } + } } -private enum AnalyticsUseCaseKey: DependencyKey { - static let liveValue: AnalyticsUseCaseProtocol = AnalyticsUseCase(manager: NoOpAnalyticsManager()) - static let testValue: AnalyticsUseCaseProtocol = AnalyticsUseCase(manager: NoOpAnalyticsManager()) +// 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: AnalyticsUseCaseProtocol { - get { self[AnalyticsUseCaseKey.self] } - set { self[AnalyticsUseCaseKey.self] = newValue } - } + 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 00000000..249aac98 --- /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 3dc0d9e3..f4b4a800 100644 --- a/Features/Login/Sources/Login/Reducer/LoginFeature.swift +++ b/Features/Login/Sources/Login/Reducer/LoginFeature.swift @@ -206,10 +206,10 @@ extension LoginFeature { // Analytics: 로그인/회원가입 구분 전송 let social = authEntity.provider.rawValue if case .signUpSuccess = outcome { - analyticsUseCase.trackSignupSuccess(socialType: social) - analyticsUseCase.trackLoginSuccess(socialType: social, isFirst: true) + analyticsUseCase.track(.auth(.signupSuccess, AuthEventData(socialType: social))) + analyticsUseCase.track(.auth(.loginSuccess, AuthEventData(socialType: social, isFirst: true))) } else { - analyticsUseCase.trackLoginSuccess(socialType: social, isFirst: false) + analyticsUseCase.track(.auth(.loginSuccess, AuthEventData(socialType: social, isFirst: false))) } return .send(.delegate(.presentTravelList)) diff --git a/Features/Main/Sources/MainCoordinator.swift b/Features/Main/Sources/MainCoordinator.swift index 5440ed73..1a49d4d0 100644 --- a/Features/Main/Sources/MainCoordinator.swift +++ b/Features/Main/Sources/MainCoordinator.swift @@ -34,7 +34,6 @@ public struct MainCoordinator { public enum DelegateAction { case presentLogin - case trackExpenseOpenDetail(travelId: String, expenseId: String, source: String) } public var body: some ReducerOf { @@ -111,12 +110,8 @@ extension MainCoordinator { action: DelegateAction ) -> Effect { switch action { - case .presentLogin: return .none - - case .trackExpenseOpenDetail: - return .none } } @@ -189,10 +184,7 @@ extension MainCoordinator { // 지출 목록 탭으로 이동하고 특정 지출을 찾아서 표시 let routeIndex = state.routes.count - 1 - return .merge( - .send(.delegate(.trackExpenseOpenDetail(travelId: travelId, expenseId: expenseId, source: "deeplink"))), - .send(.router(.routeAction(id: routeIndex, action: .settlementCoordinator(.navigateToExpenseTab(expenseId))))) - ) + return .send(.router(.routeAction(id: routeIndex, action: .settlementCoordinator(.navigateToExpenseTab(expenseId))))) } else if remainingComponents.count >= 1, remainingComponents[0] == "settlement" { #logDebug("📊 Navigating to settlement tab") diff --git a/Features/Travel/Sources/Reducer/BasicSettingFeature.swift b/Features/Travel/Sources/Reducer/BasicSettingFeature.swift index 18d0b57a..874318ec 100644 --- a/Features/Travel/Sources/Reducer/BasicSettingFeature.swift +++ b/Features/Travel/Sources/Reducer/BasicSettingFeature.swift @@ -283,7 +283,7 @@ public struct BasicSettingFeature { state.selectedCurrency = updated.baseCurrency state.exchangeRate = String(updated.baseExchangeRate) - analyticsUseCase.trackTravelUpdate(updated.id) + analyticsUseCase.track(.travel(.update, TravelEventData(travelId: updated.id))) return .merge( .send(.updated(state.travel)), diff --git a/Features/Travel/Sources/Reducer/MemberSettingFeature.swift b/Features/Travel/Sources/Reducer/MemberSettingFeature.swift index 8b1be251..0112b4fc 100644 --- a/Features/Travel/Sources/Reducer/MemberSettingFeature.swift +++ b/Features/Travel/Sources/Reducer/MemberSettingFeature.swift @@ -74,7 +74,7 @@ public struct MemberSettingFeature { state.travel = updated state.members = updated.members state.ownerId = updated.ownerName - analyticsUseCase.trackTravelOwnerDelegate(travelId: updated.id, newOwnerId: updated.ownerName) + analyticsUseCase.track(.travel(.ownerDelegate, TravelEventData(travelId: updated.id, newOwnerId: updated.ownerName))) return .send(.delegate(.needRefresh)) case .delegateOwnerResponse(.failure(let err)): @@ -100,7 +100,7 @@ public struct MemberSettingFeature { state.isSubmitting = false if let id = state.deletingMemberId { state.members.removeAll { $0.id == id } - analyticsUseCase.trackTravelMemberLeave(travelId: state.travel.id, memberId: id, role: nil) + analyticsUseCase.track(.travel(.memberLeave, TravelEventData(travelId: state.travel.id, memberId: id, role: nil))) } state.deletingMemberId = nil return .send(.delegate(.needRefresh)) diff --git a/Features/Travel/Sources/Reducer/TravelManageFeature.swift b/Features/Travel/Sources/Reducer/TravelManageFeature.swift index 92c54b33..9c1ccf72 100644 --- a/Features/Travel/Sources/Reducer/TravelManageFeature.swift +++ b/Features/Travel/Sources/Reducer/TravelManageFeature.swift @@ -80,7 +80,7 @@ public struct TravelManageFeature { case .leaveResponse(.success): state.isSubmitting = false - analyticsUseCase.trackTravelLeave(travelId: state.travelId, userId: nil) + analyticsUseCase.track(.travel(.leave, TravelEventData(travelId: state.travelId, userId: nil))) return .send(.dismissRequested) case .leaveResponse(.failure(let err)): @@ -106,7 +106,7 @@ public struct TravelManageFeature { case .deleteResponse(.success): state.isSubmitting = false - analyticsUseCase.trackTravelDelete(state.travelId) + analyticsUseCase.track(.travel(.delete, TravelEventData(travelId: state.travelId))) return .send(.dismissRequested) case .deleteResponse(.failure(let err)): diff --git a/SseuDamApp/Sources/Application/LiveDependencies.swift b/SseuDamApp/Sources/Application/LiveDependencies.swift index 419ffdd6..33daa77e 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -41,9 +41,7 @@ public enum LiveDependencies { dependencies.versionUseCase = VersionUseCase(repository: versionRepository) // Analytics - let analyticsManager = FirebaseAnalyticsManager() - dependencies.analyticsManager = analyticsManager - dependencies.analyticsUseCase = AnalyticsUseCase(manager: analyticsManager) + dependencies.analyticsUseCase = AnalyticsUseCase(repository: FirebaseAnalyticsRepository()) // Travel dependencies.fetchTravelsUseCase = FetchTravelsUseCase(repository: travelRepository) diff --git a/SseuDamApp/Sources/Reducer/AppFeature.swift b/SseuDamApp/Sources/Reducer/AppFeature.swift index ebbb1e40..f9d073c3 100644 --- a/SseuDamApp/Sources/Reducer/AppFeature.swift +++ b/SseuDamApp/Sources/Reducer/AppFeature.swift @@ -280,7 +280,7 @@ extension AppFeature { let deeplinkType = notification.userInfo?["deeplink_type"] as? String { // Analytics 이벤트 전송 - analyticsUseCase.trackDeeplinkOpen(deeplink: urlString, type: deeplinkType) + analyticsUseCase.track(.deeplink(DeeplinkEventData(deeplink: urlString, type: deeplinkType))) await send(.view(.handlePushNotificationDeepLink(urlString))) } @@ -326,14 +326,6 @@ extension AppFeature { ) ) - case .main(.delegate(.trackExpenseOpenDetail(let travelId, let expenseId, let source))): - return .run { _ in - analyticsUseCase.trackExpenseOpenDetail( - travelId: travelId, - expenseId: expenseId, - source: source - ) - } default: return .none diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 154cc6e2..fb6cffda 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -10,11 +10,6 @@ let packageSettings = PackageSettings( "TCACoordinators": .framework, "Moya": .framework, "LogMacro": .framework, - "FirebaseCore": .staticLibrary, - "FirebaseFirestore": .staticLibrary, - "FirebaseAnalytics": .staticLibrary, - "FirebaseCrashlytics": .staticLibrary, -// "GoogleSignIn": .framework ] ) From d7807ea32c48ac38fb83d9afb1d2864f226a67f8 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 12 Dec 2025 11:25:52 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20Firebase=20Analytics=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=94=A5?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Features/Main/Sources/MainCoordinator.swift | 29 +++++++++++++++++++ .../Sources/Reducer/MemberManageFeature.swift | 8 +++++ .../Reducer/TravelSettingFeature.swift | 1 + 3 files changed, 38 insertions(+) diff --git a/Features/Main/Sources/MainCoordinator.swift b/Features/Main/Sources/MainCoordinator.swift index 3aeba18f..164b31c7 100644 --- a/Features/Main/Sources/MainCoordinator.swift +++ b/Features/Main/Sources/MainCoordinator.swift @@ -102,6 +102,13 @@ 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) + $0.push(.settlementCoordinator(.init(travelId: travelId))) + } + case .routeAction(_, .memberManage(.delegate(.back))): state.routes.goBack() return .none @@ -188,6 +195,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 { // 다른 여행이거나 여행 화면이 없으면 새로 열기 diff --git a/Features/Member/Sources/Reducer/MemberManageFeature.swift b/Features/Member/Sources/Reducer/MemberManageFeature.swift index 5fa20b60..6edac5a9 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/TravelSettingFeature.swift b/Features/Travel/Sources/Reducer/TravelSettingFeature.swift index bb7e5034..9523832e 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) } } From 916b38d5834cc90f98af3b86ece95de71387fa57 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 12 Dec 2025 11:45:37 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[chore]:=20route=20=ED=95=98=EB=8A=94?= =?UTF-8?q?=EA=B1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Data/Project.swift | 2 +- Features/Main/Sources/MainCoordinator.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Data/Project.swift b/Data/Project.swift index f95e9d78..3e35c5c7 100644 --- a/Data/Project.swift +++ b/Data/Project.swift @@ -14,6 +14,6 @@ let project = Project.makeFramework( hasTests: true, settings: .settings( base: SettingsDictionary() - .otherLinkerFlags(["-all_load"]), + .otherLinkerFlags(["-all_load", "-ObjC"]), ) ) diff --git a/Features/Main/Sources/MainCoordinator.swift b/Features/Main/Sources/MainCoordinator.swift index 164b31c7..63eda5f4 100644 --- a/Features/Main/Sources/MainCoordinator.swift +++ b/Features/Main/Sources/MainCoordinator.swift @@ -106,7 +106,6 @@ extension MainCoordinator { // 여행 수정 완료 후 해당 여행의 상세 페이지로 이동 return .routeWithDelaysIfUnsupported(state.routes, action: \.router) { $0.goBackTo(\.travelList) - $0.push(.settlementCoordinator(.init(travelId: travelId))) } case .routeAction(_, .memberManage(.delegate(.back))):