diff --git a/Data/Project.swift b/Data/Project.swift index 3e35c5c..0a82a83 100644 --- a/Data/Project.swift +++ b/Data/Project.swift @@ -8,12 +8,13 @@ let project = Project.makeFramework( .NetworkService, .SPM.Supabase, .SPM.GoogleSignIn, - .SPM.FirebaseAnalytics, - .SPM.FirebaseCrashlytics, + .SPM.AppAuth, +// .SPM.FirebaseAnalytics, +// .SPM.FirebaseCrashlytics, ], hasTests: true, settings: .settings( base: SettingsDictionary() - .otherLinkerFlags(["-all_load", "-ObjC"]), +// .otherLinkerFlags(["-all_load", "-ObjC"]), ) ) diff --git a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift index 1669343..c73d585 100644 --- a/Data/Sources/Analytics/FirebaseAnalyticsManager.swift +++ b/Data/Sources/Analytics/FirebaseAnalyticsManager.swift @@ -1,85 +1,85 @@ -import Foundation -import FirebaseAnalytics -import Domain -import LogMacro - -/// FirebaseAnalytics를 사용한 Analytics Repository 구현체 -public class FirebaseAnalyticsRepository: AnalyticsRepositoryProtocol, @unchecked Sendable { - - public init() { - #logDebug("🔥 [Analytics] ===== FIREBASE ANALYTICS REPOSITORY INITIALIZED =====") - #logDebug("🔥 [Analytics] Sending app_analytics_initialized event...") - - Analytics.logEvent("app_analytics_initialized", parameters: [ - "timestamp": Date().timeIntervalSince1970, - "version": "1.0" - ]) - } - - public func sendEvent(_ event: AnalyticsEvent) async { - switch event { - case .auth(let eventType, let data): - await sendAuthEvent(eventType, data) - case .deeplink(let data): - await sendDeeplinkEvent(data) - case .travel(let eventType, let data): - await sendTravelEvent(eventType, data) - case .expense(let eventType, let data): - await sendExpenseEvent(eventType, data) - } - } - - // MARK: - Private Event Handlers - - private func sendAuthEvent(_ eventType: AuthEventType, _ data: AuthEventData) async { - var params: [String: Any] = ["social_type": data.socialType] - if let isFirst = data.isFirst { - params["is_first"] = isFirst - } - - #logDebug("🔥 [Analytics] Parameters: \(params)") - Analytics.logEvent(eventType.rawValue, parameters: params) - } - - private func sendDeeplinkEvent(_ data: DeeplinkEventData) async { - let parameters: [String: Any] = [ - "deeplink": data.deeplink, - "deeplink_type": data.type - ] - - #logDebug("🔥 [Analytics] Parameters: \(parameters)") - Analytics.logEvent("deeplink_open", parameters: parameters) - #logDebug("🔥 [Analytics] ✅ deeplink_open event sent to Firebase") - } - - private func sendTravelEvent(_ eventType: TravelEventType, _ data: TravelEventData) async { - var params: [String: Any] = ["travel_id": data.travelId] - - // Optional fields based on event type - if let userId = data.userId { params["user_id"] = userId } - if let memberId = data.memberId { params["member_id"] = memberId } - if let role = data.role { params["role"] = role } - if let newOwnerId = data.newOwnerId { params["new_owner_id"] = newOwnerId } - - #logDebug("🔥 [Analytics] Parameters: \(params)") - Analytics.logEvent(eventType.rawValue, parameters: params) - } - - private func sendExpenseEvent(_ eventType: ExpenseEventType, _ data: ExpenseEventData) async { - var params: [String: Any] = ["travel_id": data.travelId] - - // Add optional fields - if let expenseId = data.expenseId { params["expense_id"] = expenseId } - if let amount = data.amount { params["amount"] = amount } - if let currency = data.currency { params["currency"] = currency } - if let category = data.category { params["category"] = category } - if let payerId = data.payerId { params["payer_id"] = payerId } - if let source = data.source { params["source"] = source } - if let tab = data.tab { params["tab"] = tab } - if let expenseDate = data.expenseDate { params["expense_date"] = expenseDate } - if let errorCode = data.errorCode { params["error_code"] = errorCode } - - #logDebug("🔥 [Analytics] Parameters: \(params)") - Analytics.logEvent(eventType.rawValue, parameters: params) - } -} \ No newline at end of file +//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) +// } +//} diff --git a/Domain/Sources/Repository/Analytics/AnalyticsRepositoryProtocol.swift b/Domain/Sources/Repository/Analytics/AnalyticsRepositoryProtocol.swift index 5374ba1..d714cdc 100644 --- a/Domain/Sources/Repository/Analytics/AnalyticsRepositoryProtocol.swift +++ b/Domain/Sources/Repository/Analytics/AnalyticsRepositoryProtocol.swift @@ -1,6 +1,6 @@ -import Foundation - -/// Analytics 이벤트 전송을 위한 Repository 프로토콜 -public protocol AnalyticsRepositoryProtocol: Sendable { - func sendEvent(_ event: AnalyticsEvent) async -} \ No newline at end of file +//import Foundation +// +///// Analytics 이벤트 전송을 위한 Repository 프로토콜 +//public protocol AnalyticsRepositoryProtocol: Sendable { +// func sendEvent(_ event: AnalyticsEvent) async +//} diff --git a/Domain/Sources/Repository/Analytics/MockAnalyticsRepository.swift b/Domain/Sources/Repository/Analytics/MockAnalyticsRepository.swift index 1d8a988..acb40f4 100644 --- a/Domain/Sources/Repository/Analytics/MockAnalyticsRepository.swift +++ b/Domain/Sources/Repository/Analytics/MockAnalyticsRepository.swift @@ -1,94 +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) - } - } -} +//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/Router/DeeplinkDestination.swift b/Domain/Sources/Router/DeeplinkDestination.swift new file mode 100644 index 0000000..41327cf --- /dev/null +++ b/Domain/Sources/Router/DeeplinkDestination.swift @@ -0,0 +1,27 @@ +// +// DeeplinkDestination.swift +// Domain +// +// Created by Wonji Suh on 12/12/25. +// + +import Foundation + +public enum DeeplinkDestination: Equatable, Sendable { + case travel(TravelDeeplink) + case invite(code: String) + case unknown(url: String) +} + +public enum TravelDeeplink: Equatable, Sendable { + case detail(travelId: String) + case settings(travelId: String) + case expense(travelId: String, expenseId: String) + case settlement(travelId: String) +} + +public enum DeeplinkResult: Equatable, Sendable { + case success(DeeplinkDestination) + case requiresLogin(destination: DeeplinkDestination) + case invalid(url: String, reason: String) +} diff --git a/Domain/Sources/Router/DeeplinkRouter.swift b/Domain/Sources/Router/DeeplinkRouter.swift new file mode 100644 index 0000000..d146f17 --- /dev/null +++ b/Domain/Sources/Router/DeeplinkRouter.swift @@ -0,0 +1,97 @@ +// +// DeeplinkRouter.swift +// Domain +// +// Created by Wonji Suh on 12/12/25. +// + +import Foundation +import Dependencies + +public struct DeeplinkRouter: Sendable { + + public init() {} + + // MARK: - Public Interface + + public func parse(_ urlString: String) -> DeeplinkResult { + guard let url = URL(string: urlString), + url.scheme == "sseudam" else { + return .invalid(url: urlString, reason: "Invalid scheme") + } + + let pathComponents = url.pathComponents.filter { $0 != "/" } + + switch url.host ?? pathComponents.first { + case "travel": + return parseTravelDeeplink(url: url, pathComponents: pathComponents) + case "invite": + return parseInviteDeeplink(url: url) + default: + return .success(.unknown(url: urlString)) + } + } + + // MARK: - Private Parsing + + private func parseTravelDeeplink(url: URL, pathComponents: [String]) -> DeeplinkResult { + guard let (travelId, remainingComponents) = extractTravelId(url: url, pathComponents: pathComponents), + !travelId.isEmpty else { + return .invalid(url: url.absoluteString, reason: "Missing travel ID") + } + + let travelDeeplink: TravelDeeplink = { + switch remainingComponents.first { + case "settings": + return .settings(travelId: travelId) + case "expense" where remainingComponents.count >= 2: + return .expense(travelId: travelId, expenseId: remainingComponents[1]) + case "settlement": + return .settlement(travelId: travelId) + default: + return .detail(travelId: travelId) + } + }() + + return .success(.travel(travelDeeplink)) + } + + private func parseInviteDeeplink(url: URL) -> DeeplinkResult { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let inviteCode = components.queryItems?.first(where: { $0.name == "code" })?.value, + !inviteCode.isEmpty else { + return .invalid(url: url.absoluteString, reason: "Missing invite code") + } + + return .requiresLogin(destination: .invite(code: inviteCode)) + } + + private func extractTravelId(url: URL, pathComponents: [String]) -> (String, [String])? { + if pathComponents.first == "travel" && pathComponents.count >= 2 { + // ["travel", "123", "expense", "456"] + let travelId = pathComponents[1] + let remaining = Array(pathComponents.dropFirst(2)) + return (travelId, remaining) + } else if url.host == "travel" && pathComponents.count >= 1 { + // host="travel", path=["123", "expense", "456"] + let travelId = pathComponents[0] + let remaining = Array(pathComponents.dropFirst(1)) + return (travelId, remaining) + } + return nil + } +} + +// MARK: - Dependencies + +extension DeeplinkRouter: DependencyKey { + public static let liveValue = DeeplinkRouter() + public static let testValue = DeeplinkRouter() +} + +extension DependencyValues { + public var deeplinkRouter: DeeplinkRouter { + get { self[DeeplinkRouter.self] } + set { self[DeeplinkRouter.self] = newValue } + } +} diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift index abf9bee..0e5fe8f 100644 --- a/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift +++ b/Domain/Sources/UseCase/Analytics/AnalyticsUseCase.swift @@ -1,31 +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 } - } -} +//import Foundation +//import ComposableArchitecture +// +///// Analytics UseCase 구현체 +//public struct AnalyticsUseCase: AnalyticsUseCaseProtocol, Sendable { +// private let repository: any AnalyticsRepositoryProtocol +// +// public init(repository: any AnalyticsRepositoryProtocol) { +// self.repository = repository +// } +// +// public func track(_ event: AnalyticsEvent) { +// Task { +// await repository.sendEvent(event) +// } +// } +//} +// +//// MARK: - Dependency Key +// +//extension AnalyticsUseCase: DependencyKey { +// public static let liveValue: any AnalyticsUseCaseProtocol = AnalyticsUseCase(repository: MockAnalyticsRepository()) +// public static let testValue: any AnalyticsUseCaseProtocol = AnalyticsUseCase(repository: MockAnalyticsRepository()) +//} +// +//public extension DependencyValues { +// var analyticsUseCase: any AnalyticsUseCaseProtocol { +// get { self[AnalyticsUseCase.self] } +// set { self[AnalyticsUseCase.self] = newValue } +// } +//} diff --git a/Domain/Sources/UseCase/Analytics/AnalyticsUseCaseProtocol.swift b/Domain/Sources/UseCase/Analytics/AnalyticsUseCaseProtocol.swift index 249aac9..1a3528b 100644 --- a/Domain/Sources/UseCase/Analytics/AnalyticsUseCaseProtocol.swift +++ b/Domain/Sources/UseCase/Analytics/AnalyticsUseCaseProtocol.swift @@ -4,9 +4,9 @@ // // Created by Wonji Suh on 12/12/25. // - -import Foundation - -public protocol AnalyticsUseCaseProtocol: Sendable { - func track(_ event: AnalyticsEvent) -} +// +//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 f4b4a80..225e8fe 100644 --- a/Features/Login/Sources/Login/Reducer/LoginFeature.swift +++ b/Features/Login/Sources/Login/Reducer/LoginFeature.swift @@ -84,7 +84,7 @@ public struct LoginFeature { @Dependency(UnifiedOAuthUseCase.self) var unifiedOAuthUseCase @Dependency(SessionUseCase.self) var sessionUseCase - @Dependency(\.analyticsUseCase) var analyticsUseCase +// @Dependency(\.analyticsUseCase) var analyticsUseCase nonisolated enum CancelID: Hashable { @@ -206,10 +206,10 @@ extension LoginFeature { // Analytics: 로그인/회원가입 구분 전송 let social = authEntity.provider.rawValue if case .signUpSuccess = outcome { - analyticsUseCase.track(.auth(.signupSuccess, AuthEventData(socialType: social))) - analyticsUseCase.track(.auth(.loginSuccess, AuthEventData(socialType: social, isFirst: true))) +// analyticsUseCase.track(.auth(.signupSuccess, AuthEventData(socialType: social))) +// analyticsUseCase.track(.auth(.loginSuccess, AuthEventData(socialType: social, isFirst: true))) } else { - analyticsUseCase.track(.auth(.loginSuccess, AuthEventData(socialType: social, isFirst: false))) +// 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 c1a9664..e29d1a6 100644 --- a/Features/Main/Sources/MainCoordinator.swift +++ b/Features/Main/Sources/MainCoordinator.swift @@ -11,6 +11,7 @@ import ComposableArchitecture import SettlementFeature import LogMacro import MemberFeature +import Domain @Reducer public struct MainCoordinator { @@ -37,6 +38,8 @@ public struct MainCoordinator { case presentLogin } + @Dependency(\.deeplinkRouter) var deeplinkRouter + public var body: some ReducerOf { Reduce { state, action in switch action { @@ -105,11 +108,18 @@ extension MainCoordinator { state.routes.push(.memberManage(.init(travelId: travelId))) return .none + +// case .routeAction(_, .travelSetting(.delegate(.navigateToTravelDetail(_)))): +// return .routeWithDelaysIfUnsupported(state.routes, action: \.router) { +// $0.goBackTo(\.travelList) +// } + // case let .routeAction(_, .travelSetting(.delegate(.navigateToTravelDetail(travelId)))): // return .routeWithDelaysIfUnsupported(state.routes, action: \.router) { // $0.goBackTo(\.travelList) // } + case .routeAction(_, .memberManage(.delegate(.back))): state.routes.goBack() return .none @@ -166,121 +176,133 @@ extension MainCoordinator { } private func handlePushDeepLink(state: inout State, urlString: String) -> Effect { - guard let url = URL(string: urlString), - url.scheme == "sseudam" else { - #logDebug("❌ Invalid push deep link URL: \(urlString)") + #logDebug("🔗 Processing deeplink: \(urlString)") + + let result = deeplinkRouter.parse(urlString) + + switch result { + case .success(let destination): + return routeToDestination(state: &state, destination: destination) + case .requiresLogin(let destination): + return handleRequiresLogin(state: &state, destination: destination) + case .invalid(let url, let reason): + #logDebug("❌ Invalid deeplink: \(url) - \(reason)") return .none } + } - let pathComponents = url.pathComponents.filter { $0 != "/" } - #logDebug("🔗 Path components: \(pathComponents)") + // MARK: - DeepLink Routing - // URL 구조: sseudam://travel/123/expense/456 또는 sseudam://invite?code=123 - if url.host == "invite" || pathComponents.first == "invite" { - return handleInviteDeepLink(state: &state, url: url) - } else if url.host == "travel" || pathComponents.first == "travel" { - return handleTravelDeepLink(state: &state, url: url, pathComponents: pathComponents) - } else { - #logDebug("❌ Unknown deep link scheme. Host: \(url.host ?? "nil"), Path: \(pathComponents)") + private func routeToDestination( + state: inout State, + destination: DeeplinkDestination + ) -> Effect { + switch destination { + case .travel(let travelDeeplink): + return handleTravelDestination(state: &state, travelDeeplink: travelDeeplink) + case .invite(let code): + // 이미 로그인된 상태에서 초대 코드 처리 + return .send(.router(.routeAction(id: 0, action: .travelList(.openInviteCode(code))))) + case .unknown(let url): + #logDebug("🤷‍♂️ Unknown deeplink: \(url)") return .none } } - private func handleTravelDeepLink( + private func handleRequiresLogin( state: inout State, - url: URL, - pathComponents: [String] + destination: DeeplinkDestination ) -> Effect { - - var travelId: String - var remainingComponents: [String] - - // URL 구조 분석: sseudam://travel/123/expense/456 또는 sseudam://travel/{id}/... - if pathComponents.first == "travel" && pathComponents.count >= 2 { - // 표준 구조: ["travel", "123", "expense", "456"] - travelId = pathComponents[1] - remainingComponents = Array(pathComponents.dropFirst(2)) - } else if url.host == "travel" && pathComponents.count >= 1 { - // 일부 푸시 페이로드는 host에만 travel이 있고 path는 ["{id}", "expense", "{expenseId}"] 형태 - travelId = pathComponents[0] - remainingComponents = Array(pathComponents.dropFirst(1)) - } else { - #logDebug("❌ Invalid travel deep link structure: \(pathComponents)") + // 초대 코드의 경우 AppFeature로 전달하여 로그인 상태 체크 + // TODO: 필요시 다른 destination들도 처리 + guard case .invite(_) = destination else { + #logDebug("❌ Unhandled requiresLogin destination: \(destination)") return .none } + // AppFeature의 기존 로직을 사용하기 위해 다시 전달 + return .send(.delegate(.presentLogin)) + } - // 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 + private func handleTravelDestination( + state: inout State, + travelDeeplink: TravelDeeplink + ) -> Effect { + switch travelDeeplink { + case .detail(let travelId): + return navigateToTravelDetail(state: &state, travelId: travelId) + case .settings(let travelId): + return navigateToTravelSettings(state: &state, travelId: travelId) + case .expense(let travelId, let expenseId): + return navigateToExpenseDetail(state: &state, travelId: travelId, expenseId: expenseId) + case .settlement(let travelId): + return navigateToSettlementTab(state: &state, travelId: travelId) } + } - // 일반적인 여행 상세 페이지 처리 - let currentTravelId = getCurrentTravelId(from: state) - if currentTravelId != travelId { - // 다른 여행이거나 여행 화면이 없으면 새로 열기 - if let settlementIndex = state.routes.lastIndex(where: { - if case .settlementCoordinator = $0.screen { return true } - return false - }) { - // 기존 여행 화면 제거하고 새로운 여행 화면 추가 - state.routes.removeSubrange(settlementIndex...) - } - state.routes.push(.settlementCoordinator(.init(travelId: travelId))) - } + private func navigateToTravelSettings(state: inout State, travelId: String) -> Effect { + #logDebug("⚙️ Navigating to travel settings") + clearTravelRelatedScreens(state: &state) + state.routes.push(.travelSetting(.init(travelId: travelId))) + return .none + } - // 추가 네비게이션 처리 - if remainingComponents.count >= 2, remainingComponents[0] == "expense" { - let expenseId = remainingComponents[1] - #logDebug("💰 Navigating to expense detail: \(expenseId)") + private func navigateToTravelDetail(state: inout State, travelId: String) -> Effect { + #logDebug("🏝️ Navigating to travel detail") + _ = ensureSettlementCoordinatorRoute(state: &state, travelId: travelId) + return .none + } - // 지출 목록 탭으로 이동하고 특정 지출을 찾아서 표시 - let routeIndex = state.routes.count - 1 - return .send(.router(.routeAction(id: routeIndex, action: .settlementCoordinator(.navigateToExpenseTab(expenseId))))) + private func navigateToExpenseDetail(state: inout State, travelId: String, expenseId: String) -> Effect { + #logDebug("💰 Navigating to expense detail: \(expenseId)") + let routeIndex = ensureSettlementCoordinatorRoute(state: &state, travelId: travelId) + return .send(.router(.routeAction(id: routeIndex, action: .settlementCoordinator(.navigateToExpenseTab(expenseId))))) + } - } else if remainingComponents.count >= 1, remainingComponents[0] == "settlement" { - #logDebug("📊 Navigating to settlement tab") - // 정산 탭으로 이동 - let routeIndex = state.routes.count - 1 - return .send(.router(.routeAction(id: routeIndex, action: .settlementCoordinator(.navigateToSettlementTab)))) - } + private func navigateToSettlementTab(state: inout State, travelId: String) -> Effect { + #logDebug("📊 Navigating to settlement tab") + let routeIndex = ensureSettlementCoordinatorRoute(state: &state, travelId: travelId) + return .send(.router(.routeAction(id: routeIndex, action: .settlementCoordinator(.navigateToSettlementTab)))) + } - #logDebug("🏝️ Navigating to travel detail only") - return .none + private func clearTravelRelatedScreens(state: inout State) { + clearSettlementScreens(state: &state) + if let travelSettingIndex = state.routes.lastIndex(where: { + if case .travelSetting = $0.screen { return true } + return false + }) { + state.routes.removeSubrange(travelSettingIndex...) + } } - private func handleInviteDeepLink( - state: inout State, - url: URL - ) -> Effect { - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let inviteCode = components.queryItems?.first(where: { $0.name == "code" })?.value else { - print("❌ Invalid invite deep link: missing code") - return .none + private func ensureSettlementCoordinatorRoute(state: inout State, travelId: String) -> Int { + if let existingIndex = state.routes.lastIndex(where: { + if case let .settlementCoordinator(settlementState) = $0.screen { + return settlementState.travelId == travelId + } + return false + }) { + if existingIndex < state.routes.count - 1 { + state.routes.removeSubrange((existingIndex + 1)...) + } + return existingIndex } - #logDebug("🎫 Processing invite code: \(inviteCode)") + clearSettlementScreens(state: &state) + state.routes.push(.settlementCoordinator(.init(travelId: travelId))) + return state.routes.count - 1 + } - // 초대 코드 처리를 위해 TravelListFeature로 전달 - return .send(.router(.routeAction(id: 0, action: .travelList(.openInviteCode(inviteCode))))) + private func clearSettlementScreens(state: inout State) { + if let settlementIndex = state.routes.lastIndex(where: { + if case .settlementCoordinator = $0.screen { return true } + return false + }) { + state.routes.removeSubrange(settlementIndex...) + } } + private func getCurrentTravelId(from state: State) -> String? { // 현재 열려있는 SettlementCoordinator에서 travelId 추출 for route in state.routes.reversed() { diff --git a/Features/Member/Sources/Reducer/MemberManageFeature.swift b/Features/Member/Sources/Reducer/MemberManageFeature.swift index 6edac5a..17f4bc3 100644 --- a/Features/Member/Sources/Reducer/MemberManageFeature.swift +++ b/Features/Member/Sources/Reducer/MemberManageFeature.swift @@ -67,7 +67,7 @@ public struct MemberManageFeature { @Dependency(\.fetchMemberUseCase) var fetchMemberUseCase @Dependency(\.deleteTravelMemberUseCase) var deleteTravelMemberUseCase @Dependency(\.delegateOwnerUseCase) var delegateOwnerUseCase - @Dependency(\.analyticsUseCase) var analyticsUseCase +// @Dependency(\.analyticsUseCase) var analyticsUseCase public var body: some Reducer { Reduce { state, action in @@ -132,7 +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))) +// analyticsUseCase.track(.travel(.memberLeave, TravelEventData(travelId: state.travelId, memberId: memberId))) return .none case .deleteMemberResponse(.failure): @@ -191,7 +191,7 @@ public struct MemberManageFeature { // 새 관리자 ID 찾기 if let newOwnerId = travel.members.first(where: { $0.role == .owner })?.id { - analyticsUseCase.track(.travel(.ownerDelegate, TravelEventData(travelId: state.travelId, newOwnerId: newOwnerId))) +// analyticsUseCase.track(.travel(.ownerDelegate, TravelEventData(travelId: state.travelId, newOwnerId: newOwnerId))) } return .send(.delegate(.finish)) diff --git a/Features/Travel/Sources/Reducer/BasicSettingFeature.swift b/Features/Travel/Sources/Reducer/BasicSettingFeature.swift index 27eb897..165eae1 100644 --- a/Features/Travel/Sources/Reducer/BasicSettingFeature.swift +++ b/Features/Travel/Sources/Reducer/BasicSettingFeature.swift @@ -118,7 +118,7 @@ public struct BasicSettingFeature { @Dependency(\.fetchCountriesUseCase) var fetchCountriesUseCase @Dependency(\.fetchExchangeRateUseCase) var fetchExchangeRateUseCase @Dependency(\.updateTravelUseCase) var updateTravelUseCase - @Dependency(\.analyticsUseCase) var analyticsUseCase +// @Dependency(\.analyticsUseCase) var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -292,7 +292,7 @@ public struct BasicSettingFeature { state.selectedCurrency = updated.baseCurrency state.exchangeRate = String(updated.baseExchangeRate) - analyticsUseCase.track(.travel(.update, TravelEventData(travelId: updated.id))) +// analyticsUseCase.track(.travel(.update, TravelEventData(travelId: updated.id))) return .merge( .send(.updated(state.travel)), diff --git a/Features/Travel/Sources/Reducer/TravelManageFeature.swift b/Features/Travel/Sources/Reducer/TravelManageFeature.swift index 9c1ccf7..25b5fd9 100644 --- a/Features/Travel/Sources/Reducer/TravelManageFeature.swift +++ b/Features/Travel/Sources/Reducer/TravelManageFeature.swift @@ -49,7 +49,7 @@ public struct TravelManageFeature { @Dependency(\.leaveTravelUseCase) var leaveTravelUseCase @Dependency(\.deleteTravelUseCase) var deleteTravelUseCase - @Dependency(\.analyticsUseCase) var analyticsUseCase +// @Dependency(\.analyticsUseCase) var analyticsUseCase public var body: some Reducer { Reduce { state, action in @@ -80,7 +80,7 @@ public struct TravelManageFeature { case .leaveResponse(.success): state.isSubmitting = false - analyticsUseCase.track(.travel(.leave, TravelEventData(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.track(.travel(.delete, TravelEventData(travelId: state.travelId))) +// analyticsUseCase.track(.travel(.delete, TravelEventData(travelId: 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 367278a..adef294 100644 --- a/SseuDamApp/Sources/Application/AppDelegate.swift +++ b/SseuDamApp/Sources/Application/AppDelegate.swift @@ -9,8 +9,8 @@ import UIKit import UserNotifications import LogMacro import Data -import Firebase -import FirebaseAnalytics +//import Firebase +//import FirebaseAnalytics @@ -27,8 +27,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent setenv("FIRDebugEnabled", "1", 1) #endif - FirebaseApp.configure() - Analytics.setAnalyticsCollectionEnabled(true) +// FirebaseApp.configure() +// Analytics.setAnalyticsCollectionEnabled(true) let center = UNUserNotificationCenter.current() center.delegate = self diff --git a/SseuDamApp/Sources/Application/LiveDependencies.swift b/SseuDamApp/Sources/Application/LiveDependencies.swift index 7dc1305..85f8103 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -41,7 +41,7 @@ public enum LiveDependencies { dependencies.versionUseCase = VersionUseCase(repository: versionRepository) // Analytics - dependencies.analyticsUseCase = AnalyticsUseCase(repository: FirebaseAnalyticsRepository()) +// 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 f9d073c..8a7d01c 100644 --- a/SseuDamApp/Sources/Reducer/AppFeature.swift +++ b/SseuDamApp/Sources/Reducer/AppFeature.swift @@ -17,7 +17,7 @@ import Data @Reducer struct AppFeature { - @Dependency(\.analyticsUseCase) var analyticsUseCase +// @Dependency(\.analyticsUseCase) var analyticsUseCase // MARK: - State @ObservableState @@ -280,7 +280,7 @@ extension AppFeature { let deeplinkType = notification.userInfo?["deeplink_type"] as? String { // Analytics 이벤트 전송 - analyticsUseCase.track(.deeplink(DeeplinkEventData(deeplink: urlString, type: deeplinkType))) +// analyticsUseCase.track(.deeplink(DeeplinkEventData(deeplink: urlString, type: deeplinkType))) await send(.view(.handlePushNotificationDeepLink(urlString))) } diff --git a/Tuist/Package.swift b/Tuist/Package.swift index fb6cffd..1cf27f8 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -10,7 +10,13 @@ let packageSettings = PackageSettings( "TCACoordinators": .framework, "Moya": .framework, "LogMacro": .framework, - + "AppAuth": .framework +// "FirebaseCore": .staticLibrary, +// "FirebaseAuth": .staticLibrary, +// "FirebaseFirestore": .staticLibrary, +// "FirebaseAnalytics": .staticLibrary, +// "FirebaseCrashlytics": .staticLibrary, +// "FirebaseRemoteConfig": .staticLibrary ] ) #endif @@ -25,6 +31,6 @@ let package = Package( .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/firebase/firebase-ios-sdk.git", exact: "11.0.0") +// .package(url: "https://github.com/firebase/firebase-ios-sdk.git", exact: "12.7.0") ] )