diff --git a/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift b/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift index 12fc5e13..9e2589db 100644 --- a/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift +++ b/Application/DevLogApp/Sources/App/Assembler/AppLayerAssembler.swift @@ -31,5 +31,10 @@ final class AppLayerAssembler: Assembler { userService: container.resolve(UserService.self) ) } + container.register(PushNotificationOpenHandler.self) { + PushNotificationOpenHandler( + trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self) + ) + } } } diff --git a/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift b/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift index c856f936..294bb6af 100644 --- a/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift +++ b/Application/DevLogApp/Sources/App/Delegate/AppDelegate.swift @@ -52,8 +52,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // 앱이 완전 종료되어도, 알림을 통해 앱이 시작된 경우 처리 if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] { + let handler = container.resolve(PushNotificationOpenHandler.self) Task { @MainActor in - PushNotificationRoute.shared.handlePushTap(userInfo: remoteNotification) + handler.handlePushOpen(userInfo: remoteNotification) } } @@ -110,8 +111,9 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { logger.info("Tapped notification: \(response.notification.request.content.userInfo)") let userInfo = response.notification.request.content.userInfo + let handler = container.resolve(PushNotificationOpenHandler.self) Task { @MainActor in - PushNotificationRoute.shared.handlePushTap(userInfo: userInfo) + handler.handlePushOpen(userInfo: userInfo) } completionHandler() } diff --git a/Application/DevLogApp/Sources/App/DevLogApp.swift b/Application/DevLogApp/Sources/App/DevLogApp.swift index 645d183a..3737e02a 100644 --- a/Application/DevLogApp/Sources/App/DevLogApp.swift +++ b/Application/DevLogApp/Sources/App/DevLogApp.swift @@ -27,6 +27,7 @@ struct DevLogApp: App { sessionUseCase: container.resolve(ObserveAuthSessionUseCase.self), networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), systemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), + trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), widgetURLTab: { MainTab(widgetURL: $0) }, pushNotificationTodoIdPublisher: PushNotificationRoute.shared.observe(), clearPushNotificationRoute: { PushNotificationRoute.shared.clear() } diff --git a/Application/DevLogApp/Sources/App/Handler/PushNotificationOpenHandler.swift b/Application/DevLogApp/Sources/App/Handler/PushNotificationOpenHandler.swift new file mode 100644 index 00000000..c54d5790 --- /dev/null +++ b/Application/DevLogApp/Sources/App/Handler/PushNotificationOpenHandler.swift @@ -0,0 +1,22 @@ +// +// PushNotificationOpenHandler.swift +// DevLog +// +// Created by opfic on 5/28/26. +// + +import Foundation +import DevLogDomain + +final class PushNotificationOpenHandler { + private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase + + init(trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase) { + self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + } + + func handlePushOpen(userInfo: [AnyHashable: Any]) { + trackAnalyticsEventUseCase.execute(.pushOpen) + PushNotificationRoute.shared.handlePushTap(userInfo: userInfo) + } +} diff --git a/Application/DevLogApp/Sources/Resource/Info.plist b/Application/DevLogApp/Sources/Resource/Info.plist index 13e3de1b..f3bd7703 100644 --- a/Application/DevLogApp/Sources/Resource/Info.plist +++ b/Application/DevLogApp/Sources/Resource/Info.plist @@ -20,6 +20,8 @@ FirebaseAppDelegateProxyEnabled + FirebaseAutomaticScreenReportingEnabled + GIDClientID $(CLIENT_ID) GITHUB_CLIENT_ID diff --git a/Application/DevLogData/Sources/DataAssembler.swift b/Application/DevLogData/Sources/DataAssembler.swift index f79bdee0..902ee0cb 100644 --- a/Application/DevLogData/Sources/DataAssembler.swift +++ b/Application/DevLogData/Sources/DataAssembler.swift @@ -79,6 +79,12 @@ public final class DataAssembler: Assembler { UserDataRepositoryImpl(userService: container.resolve(UserService.self)) } + container.register(AnalyticsRepository.self) { + AnalyticsRepositoryImpl( + analyticsService: container.resolve(AnalyticsService.self) + ) + } + container.register(PushNotificationRepository.self) { PushNotificationRepositoryImpl( pushNotificationService: container.resolve(PushNotificationService.self), diff --git a/Application/DevLogData/Sources/Protocol/AnalyticsService.swift b/Application/DevLogData/Sources/Protocol/AnalyticsService.swift new file mode 100644 index 00000000..710d9244 --- /dev/null +++ b/Application/DevLogData/Sources/Protocol/AnalyticsService.swift @@ -0,0 +1,14 @@ +// +// AnalyticsService.swift +// DevLogData +// +// Created by opfic on 5/27/26. +// + +public protocol AnalyticsService { + func trackScreenView(_ name: String) + func trackTodoCreate() + func trackTodoComplete() + func trackWebPageCreate() + func trackPushOpen() +} diff --git a/Application/DevLogData/Sources/Repository/AnalyticsRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/AnalyticsRepositoryImpl.swift new file mode 100644 index 00000000..af36972a --- /dev/null +++ b/Application/DevLogData/Sources/Repository/AnalyticsRepositoryImpl.swift @@ -0,0 +1,31 @@ +// +// AnalyticsRepositoryImpl.swift +// DevLogData +// +// Created by opfic on 5/27/26. +// + +import DevLogDomain + +final class AnalyticsRepositoryImpl: AnalyticsRepository { + private let analyticsService: AnalyticsService + + init(analyticsService: AnalyticsService) { + self.analyticsService = analyticsService + } + + func track(_ event: AnalyticsEvent) { + switch event { + case .screenView(let name): + analyticsService.trackScreenView(name) + case .todoCreate: + analyticsService.trackTodoCreate() + case .todoComplete: + analyticsService.trackTodoComplete() + case .webPageCreate: + analyticsService.trackWebPageCreate() + case .pushOpen: + analyticsService.trackPushOpen() + } + } +} diff --git a/Application/DevLogDomain/Sources/DomainAssembler.swift b/Application/DevLogDomain/Sources/DomainAssembler.swift index 07b8fb26..4a78965c 100644 --- a/Application/DevLogDomain/Sources/DomainAssembler.swift +++ b/Application/DevLogDomain/Sources/DomainAssembler.swift @@ -11,6 +11,7 @@ public final class DomainAssembler: Assembler { public init() { } public func assemble(_ container: any DIContainer) { + registerAnalyticsUseCases(container) registerAuthUseCases(container) registerConnectivityUseCases(container) registerAuthProviderUseCases(container) @@ -24,6 +25,12 @@ public final class DomainAssembler: Assembler { } private extension DomainAssembler { + func registerAnalyticsUseCases(_ container: any DIContainer) { + container.register(TrackAnalyticsEventUseCase.self) { + TrackAnalyticsEventUseCaseImpl(container.resolve(AnalyticsRepository.self)) + } + } + func registerAuthUseCases(_ container: any DIContainer) { container.register(SignInUseCase.self) { SignInUseCaseImpl(container.resolve(AuthenticationRepository.self)) diff --git a/Application/DevLogDomain/Sources/Entity/AnalyticsEvent.swift b/Application/DevLogDomain/Sources/Entity/AnalyticsEvent.swift new file mode 100644 index 00000000..08da0aad --- /dev/null +++ b/Application/DevLogDomain/Sources/Entity/AnalyticsEvent.swift @@ -0,0 +1,14 @@ +// +// AnalyticsEvent.swift +// DevLogDomain +// +// Created by opfic on 5/28/26. +// + +public enum AnalyticsEvent { + case screenView(String) + case todoCreate + case todoComplete + case webPageCreate + case pushOpen +} diff --git a/Application/DevLogDomain/Sources/Protocol/AnalyticsRepository.swift b/Application/DevLogDomain/Sources/Protocol/AnalyticsRepository.swift new file mode 100644 index 00000000..f3887506 --- /dev/null +++ b/Application/DevLogDomain/Sources/Protocol/AnalyticsRepository.swift @@ -0,0 +1,10 @@ +// +// AnalyticsRepository.swift +// DevLogDomain +// +// Created by opfic on 5/27/26. +// + +public protocol AnalyticsRepository { + func track(_ event: AnalyticsEvent) +} diff --git a/Application/DevLogDomain/Sources/UseCase/Analytics/TrackAnalyticsEventUseCase.swift b/Application/DevLogDomain/Sources/UseCase/Analytics/TrackAnalyticsEventUseCase.swift new file mode 100644 index 00000000..8fb2ca52 --- /dev/null +++ b/Application/DevLogDomain/Sources/UseCase/Analytics/TrackAnalyticsEventUseCase.swift @@ -0,0 +1,10 @@ +// +// TrackAnalyticsEventUseCase.swift +// DevLogDomain +// +// Created by opfic on 5/27/26. +// + +public protocol TrackAnalyticsEventUseCase { + func execute(_ event: AnalyticsEvent) +} diff --git a/Application/DevLogDomain/Sources/UseCase/Analytics/TrackAnalyticsEventUseCaseImpl.swift b/Application/DevLogDomain/Sources/UseCase/Analytics/TrackAnalyticsEventUseCaseImpl.swift new file mode 100644 index 00000000..2a7d900a --- /dev/null +++ b/Application/DevLogDomain/Sources/UseCase/Analytics/TrackAnalyticsEventUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// TrackAnalyticsEventUseCaseImpl.swift +// DevLogDomain +// +// Created by opfic on 5/27/26. +// + +public final class TrackAnalyticsEventUseCaseImpl: TrackAnalyticsEventUseCase { + private let repository: AnalyticsRepository + + init(_ repository: AnalyticsRepository) { + self.repository = repository + } + + public func execute(_ event: AnalyticsEvent) { + repository.track(event) + } +} diff --git a/Application/DevLogInfra/DevLogInfra.xcodeproj/project.pbxproj b/Application/DevLogInfra/DevLogInfra.xcodeproj/project.pbxproj index 957da7a4..1bd609b2 100644 --- a/Application/DevLogInfra/DevLogInfra.xcodeproj/project.pbxproj +++ b/Application/DevLogInfra/DevLogInfra.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 3CE81EA6CD7444739AC73633 /* DevLogInfra.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 592C8B7B099933759AB316A5 /* DevLogInfra.framework */; }; 5F7A14F2C5294547884212E1 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 06ADD17826F44543B09286DA /* FirebaseCore */; }; + 62DB1494C0A5B13B49950914 /* FirebaseAnalyticsCore in Frameworks */ = {isa = PBXBuildFile; productRef = 612F6D27B38A4D058F85C8AC /* FirebaseAnalyticsCore */; }; 65623F19F34A9A75BC72EE36 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = CE8F32B22E13743B3FF3B66B /* FirebaseFirestore */; }; B11111111111111111111111 /* DevLogCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B55555555555555555555555 /* DevLogCore.framework */; }; E747320CAD03A579976E87F8 /* FirebaseFunctions in Frameworks */ = {isa = PBXBuildFile; productRef = 172AF9DE3BC54E9E0B4EFD19 /* FirebaseFunctions */; }; @@ -76,6 +77,7 @@ files = ( F75AA9259C6636CF733C4D82 /* Foundation.framework in Frameworks */, 5F7A14F2C5294547884212E1 /* FirebaseCore in Frameworks */, + 62DB1494C0A5B13B49950914 /* FirebaseAnalyticsCore in Frameworks */, FB5186BC5A89B7DADAB8A82A /* FirebaseAuth in Frameworks */, 65623F19F34A9A75BC72EE36 /* FirebaseFirestore in Frameworks */, E747320CAD03A579976E87F8 /* FirebaseFunctions in Frameworks */, @@ -181,6 +183,7 @@ ); name = DevLogInfra; packageProductDependencies = ( + 612F6D27B38A4D058F85C8AC /* FirebaseAnalyticsCore */, 06ADD17826F44543B09286DA /* FirebaseCore */, 2AC59F98E5A6BFA339C3E5BD /* FirebaseAuth */, CE8F32B22E13743B3FF3B66B /* FirebaseFirestore */, @@ -564,6 +567,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 612F6D27B38A4D058F85C8AC /* FirebaseAnalyticsCore */ = { + isa = XCSwiftPackageProductDependency; + package = 6A88F5113FA6A29A059E7035 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalyticsCore; + }; 06ADD17826F44543B09286DA /* FirebaseCore */ = { isa = XCSwiftPackageProductDependency; package = 6A88F5113FA6A29A059E7035 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/Application/DevLogInfra/Sources/InfraAssembler.swift b/Application/DevLogInfra/Sources/InfraAssembler.swift index b8d0ec04..bfa50b9d 100644 --- a/Application/DevLogInfra/Sources/InfraAssembler.swift +++ b/Application/DevLogInfra/Sources/InfraAssembler.swift @@ -16,6 +16,10 @@ public final class InfraAssembler: Assembler { FirebaseAppServiceImpl() } + container.register(AnalyticsService.self) { + FirebaseAnalyticsServiceImpl() + } + container.register(PushMessagingService.self) { PushMessagingServiceImpl() } diff --git a/Application/DevLogInfra/Sources/Service/FirebaseAnalyticsServiceImpl.swift b/Application/DevLogInfra/Sources/Service/FirebaseAnalyticsServiceImpl.swift new file mode 100644 index 00000000..6f8eff72 --- /dev/null +++ b/Application/DevLogInfra/Sources/Service/FirebaseAnalyticsServiceImpl.swift @@ -0,0 +1,43 @@ +// +// FirebaseAnalyticsServiceImpl.swift +// DevLogInfra +// +// Created by opfic on 5/27/26. +// + +import DevLogData +import FirebaseAnalytics + +final class FirebaseAnalyticsServiceImpl: AnalyticsService { + private enum EventName { + static let todoCreate = "todo_create" + static let todoComplete = "todo_complete" + static let webPageCreate = "webpage_create" + static let pushOpen = "push_open" + } + + func trackScreenView(_ name: String) { + Analytics.logEvent( + AnalyticsEventScreenView, + parameters: [ + AnalyticsParameterScreenName: name, + ] + ) + } + + func trackTodoCreate() { + Analytics.logEvent(EventName.todoCreate, parameters: nil) + } + + func trackTodoComplete() { + Analytics.logEvent(EventName.todoComplete, parameters: nil) + } + + func trackWebPageCreate() { + Analytics.logEvent(EventName.webPageCreate, parameters: nil) + } + + func trackPushOpen() { + Analytics.logEvent(EventName.pushOpen, parameters: nil) + } +} diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift index 0eafabc7..cfb78512 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewCoordinator.swift @@ -41,7 +41,8 @@ final class HomeViewCoordinator { upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), fetchTodosUseCase: fetchTodosUseCase, fetchWebPagesUseCase: fetchWebPagesUseCase, - networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self) + networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), + trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self) ) } diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift index dbda9812..c492ae5d 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift @@ -110,6 +110,7 @@ final class HomeViewModel: Store { private let fetchTodosUseCase: FetchTodosUseCase private let fetchWebPagesUseCase: FetchWebPagesUseCase private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase + private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase private let loadingState = LoadingState() private var deletedWebPageURLString: String? private var cancellables = Set() @@ -123,7 +124,8 @@ final class HomeViewModel: Store { upsertTodoUseCase: UpsertTodoUseCase, fetchTodosUseCase: FetchTodosUseCase, fetchWebPagesUseCase: FetchWebPagesUseCase, - networkConnectivityUseCase: ObserveNetworkConnectivityUseCase + networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase ) { self.fetchPreferencesUseCase = fetchPreferencesUseCase self.updatePreferencesUseCase = updatePreferencesUseCase @@ -134,6 +136,7 @@ final class HomeViewModel: Store { self.fetchTodosUseCase = fetchTodosUseCase self.fetchWebPagesUseCase = fetchWebPagesUseCase self.networkConnectivityUseCase = networkConnectivityUseCase + self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase setupNetworkObserving() } @@ -186,6 +189,7 @@ final class HomeViewModel: Store { do { defer { endLoading(for: .overlay, mode: .delayed) } try await upsertTodoUseCase.execute(todo) + trackAnalyticsEventUseCase.execute(.todoCreate) let page = try await fetchRecentTodos() let items = page.items .filter { $0.createdAt != $0.updatedAt } @@ -217,6 +221,7 @@ final class HomeViewModel: Store { do { defer { endLoading(for: .overlay, mode: .delayed) } try await addWebPageUseCase.execute(urlString) + trackAnalyticsEventUseCase.execute(.webPageCreate) let pages = try await fetchWebPagesUseCase.execute("") send(.updateWebPages(pages.map { WebPageItem(from: $0) })) } catch { diff --git a/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift b/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift index 37b9552f..ebab4b66 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift @@ -89,6 +89,7 @@ final class TodoListViewModel: Store { private let upsertTodoUseCase: UpsertTodoUseCase private let deleteTodoUseCase: DeleteTodoUseCase private let undoDeleteTodoUseCase: UndoDeleteTodoUseCase + private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase private let loadingState = LoadingState() private var undoTodoId: String? private var nextCursor: TodoCursor? @@ -101,6 +102,7 @@ final class TodoListViewModel: Store { upsertTodoUseCase: UpsertTodoUseCase, deleteTodoUseCase: DeleteTodoUseCase, undoDeleteTodoUseCase: UndoDeleteTodoUseCase, + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, category: TodoCategory ) { self.fetchTodosUseCase = fetchTodosUseCase @@ -108,6 +110,7 @@ final class TodoListViewModel: Store { self.upsertTodoUseCase = upsertTodoUseCase self.deleteTodoUseCase = deleteTodoUseCase self.undoDeleteTodoUseCase = undoDeleteTodoUseCase + self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase self.category = category self.state = State( query: TodoQuery(categoryId: category.storageValue) @@ -209,6 +212,7 @@ final class TodoListViewModel: Store { do { defer { endLoading(.delayed) } try await upsertTodoUseCase.execute(item) + trackAnalyticsEventUseCase.execute(.todoCreate) send(.refresh) } catch { send(.setAlert(true)) @@ -225,6 +229,9 @@ final class TodoListViewModel: Store { todo.completedAt = todo.isCompleted ? now : nil todo.updatedAt = now try await upsertTodoUseCase.execute(todo) + if todo.isCompleted { + trackAnalyticsEventUseCase.execute(.todoComplete) + } guard let todoListItem = TodoListItem(from: todo) else { send(.setAlert(true)) return diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index 604b063f..59b2442b 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -42,6 +42,8 @@ struct MainView: View { coordinator.mainViewModel.send(.onAppear) } .onChange(of: selectedTab, initial: true) { _, newValue in + guard let newValue else { return } + coordinator.mainViewModel.send(.selectedTabChanged(newValue)) if newValue == .home { homeViewCoordinator.fetchData() } else if newValue == .today { diff --git a/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift b/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift index ce0565d9..493b069e 100644 --- a/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift @@ -24,6 +24,7 @@ final class MainViewCoordinator { init(container: DIContainer) { self.diContainer = container self.mainViewModel = MainViewModel( + trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), unreadPushCountUseCase: container.resolve(ObserveUnreadPushCountUseCase.self) ) self.pushNotificationListViewModel = PushNotificationListViewModel( @@ -48,6 +49,7 @@ final class MainViewCoordinator { upsertTodoUseCase: diContainer.resolve(UpsertTodoUseCase.self), deleteTodoUseCase: diContainer.resolve(DeleteTodoUseCase.self), undoDeleteTodoUseCase: diContainer.resolve(UndoDeleteTodoUseCase.self), + trackAnalyticsEventUseCase: diContainer.resolve(TrackAnalyticsEventUseCase.self), category: category ) self.todoListViewModel = todoListViewModel diff --git a/Application/DevLogPresentation/Sources/Main/MainViewModel.swift b/Application/DevLogPresentation/Sources/Main/MainViewModel.swift index 707f948f..4679faaf 100644 --- a/Application/DevLogPresentation/Sources/Main/MainViewModel.swift +++ b/Application/DevLogPresentation/Sources/Main/MainViewModel.swift @@ -22,12 +22,14 @@ final class MainViewModel: Store { enum Action { case onAppear + case selectedTabChanged(MainTab) case setUnreadPushCount(Int) case setAlert(Bool) } enum SideEffect { case observeUnreadPushCount + case trackScreenView(MainTab) case updateBadgeCount(Int) } @@ -35,11 +37,14 @@ final class MainViewModel: Store { private let logger = Logger(category: "MainViewModel") private var cancellables = Set() private var isObservingUnreadPushCount = false + private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase private let unreadPushCountUseCase: ObserveUnreadPushCountUseCase init( + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, unreadPushCountUseCase: ObserveUnreadPushCountUseCase ) { + self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase self.unreadPushCountUseCase = unreadPushCountUseCase } @@ -53,6 +58,10 @@ final class MainViewModel: Store { isObservingUnreadPushCount = true sideEffects = [.observeUnreadPushCount] } + case .selectedTabChanged(let tab): + if tab.analyticsScreenName != nil { + sideEffects = [.trackScreenView(tab)] + } case .setUnreadPushCount(let count): state.unreadPushCount = count sideEffects = [.updateBadgeCount(count)] @@ -68,6 +77,8 @@ final class MainViewModel: Store { switch effect { case .observeUnreadPushCount: observeUnreadPushCount() + case .trackScreenView(let tab): + trackScreenView(tab) case .updateBadgeCount(let count): updateBadgeCount(count) } @@ -116,4 +127,24 @@ private extension MainViewModel { } } } + + func trackScreenView(_ tab: MainTab) { + guard let screenName = tab.analyticsScreenName else { return } + trackAnalyticsEventUseCase.execute(.screenView(screenName)) + } +} + +private extension MainTab { + var analyticsScreenName: String? { + switch self { + case .home: + return "home" + case .today: + return "today" + case .notification: + return nil + case .profile: + return "profile" + } + } } diff --git a/Application/DevLogPresentation/Sources/Root/RootView.swift b/Application/DevLogPresentation/Sources/Root/RootView.swift index 81c34307..0ba4bec5 100644 --- a/Application/DevLogPresentation/Sources/Root/RootView.swift +++ b/Application/DevLogPresentation/Sources/Root/RootView.swift @@ -23,6 +23,7 @@ public struct RootView: View { sessionUseCase: ObserveAuthSessionUseCase, networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, systemThemeUseCase: ObserveSystemThemeUseCase, + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, widgetURLTab: @escaping (URL) -> MainTab?, pushNotificationTodoIdPublisher: AnyPublisher, clearPushNotificationRoute: @escaping () -> Void @@ -30,7 +31,8 @@ public struct RootView: View { self._viewModel = State(initialValue: RootViewModel( sessionUseCase: sessionUseCase, networkConnectivityUseCase: networkConnectivityUseCase, - systemThemeUseCase: systemThemeUseCase + systemThemeUseCase: systemThemeUseCase, + trackAnalyticsEventUseCase: trackAnalyticsEventUseCase )) self.widgetURLTab = widgetURLTab self.pushNotificationTodoIdPublisher = pushNotificationTodoIdPublisher diff --git a/Application/DevLogPresentation/Sources/Root/RootViewModel.swift b/Application/DevLogPresentation/Sources/Root/RootViewModel.swift index 73c336f5..3c63f0da 100644 --- a/Application/DevLogPresentation/Sources/Root/RootViewModel.swift +++ b/Application/DevLogPresentation/Sources/Root/RootViewModel.swift @@ -32,6 +32,7 @@ final class RootViewModel: Store { enum SideEffect { case clearApplicationBadgeCount + case trackLoginScreen } private(set) var state: State @@ -39,15 +40,18 @@ final class RootViewModel: Store { private let sessionUseCase: ObserveAuthSessionUseCase private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase private let systemThemeUseCase: ObserveSystemThemeUseCase + private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase init( sessionUseCase: ObserveAuthSessionUseCase, networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, - systemThemeUseCase: ObserveSystemThemeUseCase + systemThemeUseCase: ObserveSystemThemeUseCase, + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase ) { self.sessionUseCase = sessionUseCase self.networkConnectivityUseCase = networkConnectivityUseCase self.systemThemeUseCase = systemThemeUseCase + self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase self.state = State() setupNetworkObserving() @@ -74,6 +78,9 @@ final class RootViewModel: Store { state.theme = theme case .didLogined(let result): state.signIn = result + if !result { + effects = [.trackLoginScreen] + } } if self.state != state { self.state = state } @@ -84,6 +91,8 @@ final class RootViewModel: Store { switch effect { case .clearApplicationBadgeCount: UNUserNotificationCenter.current().setBadgeCount(0) { _ in } + case .trackLoginScreen: + trackAnalyticsEventUseCase.execute(.screenView("login")) } } } diff --git a/Application/DevLogPresentation/Sources/Today/TodayViewCoordinator.swift b/Application/DevLogPresentation/Sources/Today/TodayViewCoordinator.swift index 290bb0d3..22b052bf 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayViewCoordinator.swift @@ -21,7 +21,8 @@ final class TodayViewCoordinator { fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), fetchTodayDisplayOptionsUseCase: container.resolve(FetchTodayDisplayOptionsUseCase.self), - updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self) + updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self), + trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self) ) } diff --git a/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift b/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift index 0281bfbb..ccaef572 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift @@ -83,6 +83,7 @@ final class TodayViewModel: Store { private let fetchTodoByIdUseCase: FetchTodoByIdUseCase private let upsertTodoUseCase: UpsertTodoUseCase private let updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase + private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase private let loadingState = LoadingState() init( @@ -90,12 +91,14 @@ final class TodayViewModel: Store { fetchTodoByIdUseCase: FetchTodoByIdUseCase, upsertTodoUseCase: UpsertTodoUseCase, fetchTodayDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase, - updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase + updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase, + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase ) { self.fetchTodosUseCase = fetchTodosUseCase self.fetchTodoByIdUseCase = fetchTodoByIdUseCase self.upsertTodoUseCase = upsertTodoUseCase self.updateTodayDisplayOptionsUseCase = updateTodayDisplayOptionsUseCase + self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase self.state.displayOptions = fetchTodayDisplayOptionsUseCase.execute() } @@ -235,6 +238,7 @@ final class TodayViewModel: Store { todo.completedAt = now todo.updatedAt = now try await upsertTodoUseCase.execute(todo) + trackAnalyticsEventUseCase.execute(.todoComplete) send(.removeTodo(todo.id)) } catch { send(.setAlert(true)) diff --git a/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift b/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift index 78726cf5..2ee7ea76 100644 --- a/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift +++ b/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift @@ -32,6 +32,7 @@ struct DeleteWebPageTests { ] ) let observeNetworkConnectivityUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() + let trackAnalyticsEventUseCaseSpy = TrackAnalyticsEventUseCaseSpy() let homeViewModel = HomeViewModel( fetchPreferencesUseCase: fetchTodoCategoryPreferencesUseCaseSpy, @@ -42,7 +43,8 @@ struct DeleteWebPageTests { upsertTodoUseCase: upsertTodoUseCaseSpy, fetchTodosUseCase: fetchTodosUseCaseSpy, fetchWebPagesUseCase: fetchWebPagesUseCaseSpy, - networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy + networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy, + trackAnalyticsEventUseCase: trackAnalyticsEventUseCaseSpy ) homeViewModel.send(.fetchData) @@ -84,6 +86,7 @@ struct DeleteWebPageTests { ] ) let observeNetworkConnectivityUseCaseSpy = ObserveNetworkConnectivityUseCaseSpy() + let trackAnalyticsEventUseCaseSpy = TrackAnalyticsEventUseCaseSpy() let homeViewModel = HomeViewModel( fetchPreferencesUseCase: fetchTodoCategoryPreferencesUseCaseSpy, @@ -94,7 +97,8 @@ struct DeleteWebPageTests { upsertTodoUseCase: upsertTodoUseCaseSpy, fetchTodosUseCase: fetchTodosUseCaseSpy, fetchWebPagesUseCase: fetchWebPagesUseCaseSpy, - networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy + networkConnectivityUseCase: observeNetworkConnectivityUseCaseSpy, + trackAnalyticsEventUseCase: trackAnalyticsEventUseCaseSpy ) homeViewModel.send(.fetchData) @@ -119,3 +123,7 @@ struct DeleteWebPageTests { #expect(!restoredWebPageItem.isHidden) } } + +private struct TrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { + func execute(_ event: AnalyticsEvent) { } +}