Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ DerivedData/
!default.perspectivev3
Config/
.env

GoogleService-Info.plist
## Obj-C/Swift specific
*.hmap

Expand Down
2 changes: 1 addition & 1 deletion .mise.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[tools]
tuist = "4.68.0"
tuist = "4.113.0"
7 changes: 6 additions & 1 deletion Data/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
)
)
85 changes: 85 additions & 0 deletions Data/Sources/Analytics/FirebaseAnalyticsManager.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion Data/Sources/Repository/Kakao/KakaoOAuthRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions Domain/Sources/Entity/Analytics/AnalyticsEvent.swift
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 18 additions & 0 deletions Domain/Sources/Entity/Analytics/AuthEventData.swift
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 12 additions & 0 deletions Domain/Sources/Entity/Analytics/DeeplinkEventData.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
49 changes: 49 additions & 0 deletions Domain/Sources/Entity/Analytics/ExpenseEventData.swift
Original file line number Diff line number Diff line change
@@ -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"
}
33 changes: 33 additions & 0 deletions Domain/Sources/Entity/Analytics/TravelEventData.swift
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

/// Analytics ์ด๋ฒคํŠธ ์ „์†ก์„ ์œ„ํ•œ Repository ํ”„๋กœํ† ์ฝœ
public protocol AnalyticsRepositoryProtocol: Sendable {
func sendEvent(_ event: AnalyticsEvent) async
}
94 changes: 94 additions & 0 deletions Domain/Sources/Repository/Analytics/MockAnalyticsRepository.swift
Original file line number Diff line number Diff line change
@@ -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<T>(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<T>: @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)
}
}
}
31 changes: 31 additions & 0 deletions Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Loading