From 7505db171b07bd00c6a7244a825eca2b74080889 Mon Sep 17 00:00:00 2001 From: TomBumSuChoi Date: Thu, 7 May 2026 22:31:20 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(crashlytics):=20dSYM=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20Tuist=20=EA=B2=BD=EB=A1=9C=EB=8F=84=20=EC=B0=BE?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Project.swift b/Project.swift index b02a050e..97c41e01 100644 --- a/Project.swift +++ b/Project.swift @@ -113,7 +113,9 @@ let project = Project( scripts: [ .post( script: """ - "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run" + SCRIPT="${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run" + [ -f "$SCRIPT" ] || SCRIPT="${SRCROOT}/Tuist/.build/checkouts/firebase-ios-sdk/Crashlytics/run" + "$SCRIPT" """, name: "Firebase Crashlytics dSYM Upload", inputPaths: [ From 5673e4e939a2760c1d5a14c48e9396c7e266f4d2 Mon Sep 17 00:00:00 2001 From: TomBumSuChoi Date: Thu, 7 May 2026 22:31:24 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(analytics):=20Analytics=20SDK=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Debug=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20DebugView=20=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Project.swift | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Project.swift b/Project.swift index 97c41e01..33b85eef 100644 --- a/Project.swift +++ b/Project.swift @@ -50,6 +50,7 @@ let project = Project( .external(name: "FirebaseStorage"), .external(name: "FirebaseMessaging"), .external(name: "FirebaseCrashlytics"), + .external(name: "FirebaseAnalytics"), .external(name: "GoogleSignIn"), .external(name: "GoogleSignInSwift") ] @@ -164,5 +165,27 @@ let project = Project( .target(name: "MyI") ] ) + ], + schemes: [ + .scheme( + name: "MyI", + shared: true, + buildAction: .buildAction(targets: ["MyI"]), + testAction: .targets( + ["FeaturesTests", "MyITests"], + configuration: "Debug" + ), + runAction: .runAction( + configuration: "Debug", + arguments: .arguments( + launchArguments: [ + .launchArgument(name: "-FIRDebugEnabled", isEnabled: true) + ] + ) + ), + archiveAction: .archiveAction(configuration: "Release"), + profileAction: .profileAction(configuration: "Release"), + analyzeAction: .analyzeAction(configuration: "Debug") + ) ] ) From f889e412bb5096dc47ec7217ccce0068c975d95e Mon Sep 17 00:00:00 2001 From: TomBumSuChoi Date: Thu, 7 May 2026 22:31:32 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(analytics):=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=8D=B8=EA=B3=BC=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnalyticsClient+Live.swift | 72 +++++++++++++++++++ .../AnalyticsClient+Test.swift | 13 ++++ Domain/Analytics/AnalyticsClient.swift | 20 ++++++ Domain/Analytics/AnalyticsEnums.swift | 41 +++++++++++ Domain/Analytics/AnalyticsEvent.swift | 13 ++++ Domain/Analytics/ScreenName.swift | 15 ++++ Domain/Analytics/UserProperty.swift | 8 +++ 7 files changed, 182 insertions(+) create mode 100644 Clients/AnalyticsClientLive/AnalyticsClient+Live.swift create mode 100644 Clients/AnalyticsClientLive/AnalyticsClient+Test.swift create mode 100644 Domain/Analytics/AnalyticsClient.swift create mode 100644 Domain/Analytics/AnalyticsEnums.swift create mode 100644 Domain/Analytics/AnalyticsEvent.swift create mode 100644 Domain/Analytics/ScreenName.swift create mode 100644 Domain/Analytics/UserProperty.swift diff --git a/Clients/AnalyticsClientLive/AnalyticsClient+Live.swift b/Clients/AnalyticsClientLive/AnalyticsClient+Live.swift new file mode 100644 index 00000000..c2076192 --- /dev/null +++ b/Clients/AnalyticsClientLive/AnalyticsClient+Live.swift @@ -0,0 +1,72 @@ +import ComposableArchitecture +import Domain +import FirebaseAnalytics +import Foundation + +extension AnalyticsClient: @retroactive DependencyKey { + public static let liveValue: AnalyticsClient = .init( + track: { event in + let (name, params) = mapEvent(event) + Analytics.logEvent(name, parameters: params) + }, + trackScreen: { screen in + Analytics.logEvent( + AnalyticsEventScreenView, + parameters: [ + AnalyticsParameterScreenName: screen.rawValue, + AnalyticsParameterScreenClass: screen.rawValue + ] + ) + }, + setUserID: { uid in + Analytics.setUserID(uid) + }, + setUserProperty: { property in + let (name, value) = mapProperty(property) + Analytics.setUserProperty(value, forName: name) + } + ) +} + +extension DependencyValues { + public var analytics: AnalyticsClient { + get { self[AnalyticsClient.self] } + set { self[AnalyticsClient.self] = newValue } + } +} + +private func mapEvent(_ event: AnalyticsEvent) -> (String, [String: Any]?) { + switch event { + case .cryAnalysisStarted: + ("cry_analysis_started", nil) + case let .cryAnalysisResultViewed(emotion): + ("cry_analysis_result_viewed", ["emotion": emotion.rawValue]) + case let .careRecordSaved(category): + ("care_record_saved", ["category": category.rawValue]) + case let .noteCreated(noteID): + ("note_created", ["note_id": noteID.uuidString]) + case let .noteUpdated(noteID): + ("note_updated", ["note_id": noteID.uuidString]) + case .caregiverInviteStarted: + ("caregiver_invite_started", nil) + case .caregiverInviteCompleted: + ("caregiver_invite_completed", nil) + case let .statisticViewed(period): + ("statistic_viewed", ["period": period.rawValue]) + case .statisticPDFExported: + ("statistic_pdf_exported", nil) + } +} + +private func mapProperty(_ property: UserProperty) -> (String, String?) { + switch property { + case let .babyCount(count): + ("baby_count", String(count)) + case let .hasCaregiver(flag): + ("has_caregiver", flag ? "true" : "false") + case let .babyAgeMonthsBucket(bucket): + ("baby_age_months", bucket.rawValue) + case let .authProvider(provider): + ("auth_provider", provider.rawValue) + } +} diff --git a/Clients/AnalyticsClientLive/AnalyticsClient+Test.swift b/Clients/AnalyticsClientLive/AnalyticsClient+Test.swift new file mode 100644 index 00000000..7dbaab9c --- /dev/null +++ b/Clients/AnalyticsClientLive/AnalyticsClient+Test.swift @@ -0,0 +1,13 @@ +import ComposableArchitecture +import Domain + +extension AnalyticsClient: @retroactive TestDependencyKey { + public static let testValue: AnalyticsClient = .init( + track: { _ in }, + trackScreen: { _ in }, + setUserID: { _ in }, + setUserProperty: { _ in } + ) + + public static let previewValue: AnalyticsClient = testValue +} diff --git a/Domain/Analytics/AnalyticsClient.swift b/Domain/Analytics/AnalyticsClient.swift new file mode 100644 index 00000000..59692ba0 --- /dev/null +++ b/Domain/Analytics/AnalyticsClient.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct AnalyticsClient: Sendable { + public var track: @Sendable (AnalyticsEvent) -> Void + public var trackScreen: @Sendable (ScreenName) -> Void + public var setUserID: @Sendable (String?) -> Void + public var setUserProperty: @Sendable (UserProperty) -> Void + + public init( + track: @escaping @Sendable (AnalyticsEvent) -> Void, + trackScreen: @escaping @Sendable (ScreenName) -> Void, + setUserID: @escaping @Sendable (String?) -> Void, + setUserProperty: @escaping @Sendable (UserProperty) -> Void + ) { + self.track = track + self.trackScreen = trackScreen + self.setUserID = setUserID + self.setUserProperty = setUserProperty + } +} diff --git a/Domain/Analytics/AnalyticsEnums.swift b/Domain/Analytics/AnalyticsEnums.swift new file mode 100644 index 00000000..2955e582 --- /dev/null +++ b/Domain/Analytics/AnalyticsEnums.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum BabyAgeBucket: String, Sendable, Equatable { + case zeroToThree = "0-3" + case fourToSix = "4-6" + case sevenToTwelve = "7-12" + case thirteenToTwentyFour = "13-24" + case twentyFivePlus = "25+" + + public init(monthsOld: Int) { + switch monthsOld { + case ..<4: self = .zeroToThree + case 4 ... 6: self = .fourToSix + case 7 ... 12: self = .sevenToTwelve + case 13 ... 24: self = .thirteenToTwentyFour + default: self = .twentyFivePlus + } + } +} + +public enum AnalyticsAuthProvider: String, Sendable, Equatable { + case apple + case google + case unknown + + public init(providerIDs: [String]) { + if providerIDs.contains("apple.com") { + self = .apple + } else if providerIDs.contains("google.com") { + self = .google + } else { + self = .unknown + } + } +} + +public enum StatisticPeriod: String, Sendable, Equatable { + case day + case week + case month +} diff --git a/Domain/Analytics/AnalyticsEvent.swift b/Domain/Analytics/AnalyticsEvent.swift new file mode 100644 index 00000000..9d140ade --- /dev/null +++ b/Domain/Analytics/AnalyticsEvent.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum AnalyticsEvent: Sendable, Equatable { + case cryAnalysisStarted + case cryAnalysisResultViewed(emotion: EmotionType) + case careRecordSaved(category: CareEvent.Category) + case noteCreated(noteID: UUID) + case noteUpdated(noteID: UUID) + case caregiverInviteStarted + case caregiverInviteCompleted + case statisticViewed(period: StatisticPeriod) + case statisticPDFExported +} diff --git a/Domain/Analytics/ScreenName.swift b/Domain/Analytics/ScreenName.swift new file mode 100644 index 00000000..117a3711 --- /dev/null +++ b/Domain/Analytics/ScreenName.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum ScreenName: String, Sendable, Equatable { + case home + case cryAnalysisHome = "cry_analysis_home" + case cryAnalysisRunning = "cry_analysis_running" + case cryAnalysisResult = "cry_analysis_result" + case noteList = "note_list" + case noteEditor = "note_editor" + case babyRegister = "baby_register" + case caregiverInvite = "caregiver_invite" + case statistic + case settings + case login +} diff --git a/Domain/Analytics/UserProperty.swift b/Domain/Analytics/UserProperty.swift new file mode 100644 index 00000000..7766ec75 --- /dev/null +++ b/Domain/Analytics/UserProperty.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum UserProperty: Sendable, Equatable { + case babyCount(Int) + case hasCaregiver(Bool) + case babyAgeMonthsBucket(BabyAgeBucket) + case authProvider(AnalyticsAuthProvider) +} From 3d7c17329ac878ff86b9b776ecbb46cc6dfae6d7 Mon Sep 17 00:00:00 2001 From: TomBumSuChoi Date: Thu, 7 May 2026 22:31:36 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(analytics):=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9E=90=EC=99=80=20=EC=86=8D=EC=84=B1=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Clients/Bootstrap.swift | 2 ++ Features/MainTab/MainTabFeature.swift | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Clients/Bootstrap.swift b/Clients/Bootstrap.swift index 52b9dca5..7e025f19 100644 --- a/Clients/Bootstrap.swift +++ b/Clients/Bootstrap.swift @@ -1,4 +1,5 @@ import Domain +@preconcurrency import FirebaseAnalytics @preconcurrency import FirebaseAuth import FirebaseCore @preconcurrency import FirebaseCrashlytics @@ -27,6 +28,7 @@ public enum AppBootstrap { Auth.auth().addStateDidChangeListener { _, user in Crashlytics.crashlytics().setUserID(user?.uid ?? "") + Analytics.setUserID(user?.uid) } AppLogger.info("AppBootstrap configured") diff --git a/Features/MainTab/MainTabFeature.swift b/Features/MainTab/MainTabFeature.swift index 1b09ba96..dcf73df8 100644 --- a/Features/MainTab/MainTabFeature.swift +++ b/Features/MainTab/MainTabFeature.swift @@ -67,6 +67,7 @@ public struct MainTabFeature { case notificationSync(NotificationSyncFeature.Action) } + @Dependency(\.analytics) var analytics @Dependency(\.babyClient) var babyClient @Dependency(\.caregiverClient) var caregiverClient @@ -86,6 +87,29 @@ public struct MainTabFeature { state.statistic.baby = baby } + private func emitUserProperties(state: State) -> Effect { + let babyCount = state.babies.count + let hasCaregiver = (state.babies.first?.caregiverIDs.count ?? 0) > 1 + let bucket: BabyAgeBucket = { + guard let baby = state.babies.first else { return .zeroToThree } + + let months = Calendar.current.dateComponents( + [.month], + from: baby.birthDate, + to: Date() + ).month ?? 0 + return BabyAgeBucket(monthsOld: months) + }() + let provider = AnalyticsAuthProvider(providerIDs: state.session.providerIDs) + + return .run { [analytics] _ in + analytics.setUserProperty(.babyCount(babyCount)) + analytics.setUserProperty(.hasCaregiver(hasCaregiver)) + analytics.setUserProperty(.babyAgeMonthsBucket(bucket)) + analytics.setUserProperty(.authProvider(provider)) + } + } + public var body: some ReducerOf { BindingReducer() Scope(state: \.home, action: \.home) { @@ -126,6 +150,7 @@ public struct MainTabFeature { case .view(.task): return .merge( .send(.notificationSync(.view(.task))), + emitUserProperties(state: state), .run { [babyClient] send in for await babies in babyClient.streamBabies() { await send(._internal(.babiesLoaded(babies))) @@ -145,7 +170,7 @@ public struct MainTabFeature { state.settings.babies = state.babies state.home.babies = state.babies propagateSelectedBaby(into: &state) - return .none + return emitUserProperties(state: state) case let ._internal(.caregiverLoaded(caregiver)): guard let caregiver else { return .none } From d24d73549eb4b1a9bac730f8c2a44ba416485b95 Mon Sep 17 00:00:00 2001 From: TomBumSuChoi Date: Thu, 7 May 2026 22:31:41 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(analytics):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=EA=B3=BC=20=EC=A3=BC=EC=9A=94=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=ED=96=89=EB=8F=99=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BabyRegisterFlowFeature.swift | 7 ++ .../BabyRegister/BabyRegisterFlowView.swift | 1 + .../ExistingBabyRegisterFeature.swift | 9 ++- .../Analysis/CryAnalysisFeature.swift | 76 +++++++++++-------- .../Home/CryAnalysisHomeFeature.swift | 5 ++ .../Home/CryAnalysisHomeView.swift | 1 + Features/Home/HomeFeature.swift | 12 ++- Features/Login/LoginFeature.swift | 5 ++ Features/Login/LoginView.swift | 1 + Features/Note/Diary/DiaryEditorFeature.swift | 8 +- Features/Note/Diary/DiaryEditorView.swift | 1 + Features/Note/Home/NoteHomeFeature.swift | 9 ++- .../Note/Schedule/ScheduleEditorFeature.swift | 4 +- .../Caregivers/CaregiverListFeature.swift | 14 ++-- Features/Settings/SettingsFeature.swift | 5 ++ Features/Settings/SettingsView.swift | 1 + Features/Statistic/StatisticFeature.swift | 26 ++++++- 17 files changed, 139 insertions(+), 46 deletions(-) diff --git a/Features/BabyRegister/BabyRegisterFlowFeature.swift b/Features/BabyRegister/BabyRegisterFlowFeature.swift index 6ca05b08..6bf9ab53 100644 --- a/Features/BabyRegister/BabyRegisterFlowFeature.swift +++ b/Features/BabyRegister/BabyRegisterFlowFeature.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import Domain import Foundation @Reducer @@ -13,6 +14,7 @@ public struct BabyRegisterFlowFeature { public enum Action { public enum ViewAction { + case task case cancelTapped } @@ -28,6 +30,8 @@ public struct BabyRegisterFlowFeature { case path(StackActionOf) } + @Dependency(\.analytics) var analytics + public init() {} public var body: some ReducerOf { @@ -36,6 +40,9 @@ public struct BabyRegisterFlowFeature { } Reduce { state, action in switch action { + case .view(.task): + return .run { [analytics] _ in analytics.trackScreen(.babyRegister) } + case .view(.cancelTapped): return .send(.delegate(.cancelled)) diff --git a/Features/BabyRegister/BabyRegisterFlowView.swift b/Features/BabyRegister/BabyRegisterFlowView.swift index 839e086d..175faf38 100644 --- a/Features/BabyRegister/BabyRegisterFlowView.swift +++ b/Features/BabyRegister/BabyRegisterFlowView.swift @@ -25,5 +25,6 @@ public struct BabyRegisterFlowView: View { ExistingBabyRegisterView(store: existingStore) } } + .task { store.send(.view(.task)) } } } diff --git a/Features/BabyRegister/Existing/ExistingBabyRegisterFeature.swift b/Features/BabyRegister/Existing/ExistingBabyRegisterFeature.swift index d141d65c..c3db2244 100644 --- a/Features/BabyRegister/Existing/ExistingBabyRegisterFeature.swift +++ b/Features/BabyRegister/Existing/ExistingBabyRegisterFeature.swift @@ -42,6 +42,7 @@ public struct ExistingBabyRegisterFeature { case alert(PresentationAction) } + @Dependency(\.analytics) var analytics @Dependency(\.babyClient) var babyClient public init() {} @@ -61,7 +62,8 @@ public struct ExistingBabyRegisterFeature { state.isSubmitting = true - return .run { [babyClient] send in + return .run { [analytics, babyClient] send in + analytics.track(.caregiverInviteStarted) do throws(BabyError) { try await babyClient.registerExistingBaby(babyID) await send(._internal(.submitSucceeded)) @@ -72,7 +74,10 @@ public struct ExistingBabyRegisterFeature { case ._internal(.submitSucceeded): state.isSubmitting = false - return .send(.delegate(.completed)) + return .merge( + .run { [analytics] _ in analytics.track(.caregiverInviteCompleted) }, + .send(.delegate(.completed)) + ) case let ._internal(.submitFailed(error)): state.isSubmitting = false diff --git a/Features/CryAnalysis/Analysis/CryAnalysisFeature.swift b/Features/CryAnalysis/Analysis/CryAnalysisFeature.swift index f404c56b..49fa2437 100644 --- a/Features/CryAnalysis/Analysis/CryAnalysisFeature.swift +++ b/Features/CryAnalysis/Analysis/CryAnalysisFeature.swift @@ -37,6 +37,7 @@ public struct CryAnalysisFeature { } @Dependency(\.audioRecorderClient) var audioRecorderClient + @Dependency(\.analytics) var analytics @Dependency(\.cryAnalysisClient) var cryAnalysisClient @Dependency(\.cryRecordClient) var cryRecordClient @Dependency(\.continuousClock) var clock @@ -56,24 +57,27 @@ public struct CryAnalysisFeature { case let .view(viewAction): switch viewAction { case .task: - return .run { [audioRecorderClient, uuid] send in - let url = FileManager.default.temporaryDirectory - .appendingPathComponent(uuid().uuidString) - .appendingPathExtension("caf") - await withTaskCancellationHandler { - do { - try await audioRecorderClient.startRecording(url) - } catch { - await send(._internal(.recordingFailed)) + return .merge( + .run { [analytics] _ in analytics.trackScreen(.cryAnalysisRunning) }, + .run { [audioRecorderClient, uuid] send in + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(uuid().uuidString) + .appendingPathExtension("caf") + await withTaskCancellationHandler { + do { + try await audioRecorderClient.startRecording(url) + } catch { + await send(._internal(.recordingFailed)) + try? FileManager.default.removeItem(at: url) + return + } + await send(._internal(.tickingStarted(url))) + } onCancel: { try? FileManager.default.removeItem(at: url) - return } - await send(._internal(.tickingStarted(url))) - } onCancel: { - try? FileManager.default.removeItem(at: url) } - } - .cancellable(id: CancelID.lifecycle) + .cancellable(id: CancelID.lifecycle) + ) case .cancelTapped: return .merge( @@ -120,20 +124,23 @@ public struct CryAnalysisFeature { .cancellable(id: CancelID.lifecycle) case let .recordingFinished(url): - return .run { [cryAnalysisClient] send in - await withTaskCancellationHandler { - do { - let record = try await cryAnalysisClient.analyze(url) - await send(._internal(.analysisSucceeded(record))) - } catch { - await send(._internal(.analysisFailed)) + return .merge( + .run { [analytics] _ in analytics.track(.cryAnalysisStarted) }, + .run { [cryAnalysisClient] send in + await withTaskCancellationHandler { + do { + let record = try await cryAnalysisClient.analyze(url) + await send(._internal(.analysisSucceeded(record))) + } catch { + await send(._internal(.analysisFailed)) + } + try? FileManager.default.removeItem(at: url) + } onCancel: { + try? FileManager.default.removeItem(at: url) } - try? FileManager.default.removeItem(at: url) - } onCancel: { - try? FileManager.default.removeItem(at: url) } - } - .cancellable(id: CancelID.lifecycle) + .cancellable(id: CancelID.lifecycle) + ) case .recordingFailed: state.stage = .failure(.recordingFailed) @@ -150,15 +157,22 @@ public struct CryAnalysisFeature { case let .analysisSucceeded(record): let scores = record.aggregatedScores + let primary = scores.first ?? EmotionScore(emotion: .unknown, confidence: 0) state.stage = .result( ResultDisplay( - primary: scores.first ?? EmotionScore(emotion: .unknown, confidence: 0), + primary: primary, others: Array(scores.dropFirst().prefix(3)) ) ) - return .run { [cryRecordClient, babyID = state.baby.id, record] _ in - try? await cryRecordClient.addRecord(babyID, record) - } + return .merge( + .run { [analytics, emotion = primary.emotion] _ in + analytics.trackScreen(.cryAnalysisResult) + analytics.track(.cryAnalysisResultViewed(emotion: emotion)) + }, + .run { [cryRecordClient, babyID = state.baby.id, record] _ in + try? await cryRecordClient.addRecord(babyID, record) + } + ) } } } diff --git a/Features/CryAnalysis/Home/CryAnalysisHomeFeature.swift b/Features/CryAnalysis/Home/CryAnalysisHomeFeature.swift index 34affb88..554e9ecc 100644 --- a/Features/CryAnalysis/Home/CryAnalysisHomeFeature.swift +++ b/Features/CryAnalysis/Home/CryAnalysisHomeFeature.swift @@ -17,6 +17,7 @@ public struct CryAnalysisHomeFeature { public enum Action { public enum ViewAction { + case task case startTapped case recordsButtonTapped } @@ -36,6 +37,7 @@ public struct CryAnalysisHomeFeature { case alert(PresentationAction) } + @Dependency(\.analytics) var analytics @Dependency(\.audioRecorderClient) var audioRecorderClient @Dependency(\.openURL) var openURL @@ -44,6 +46,9 @@ public struct CryAnalysisHomeFeature { public var body: some ReducerOf { Reduce { state, action in switch action { + case .view(.task): + return .run { [analytics] _ in analytics.trackScreen(.cryAnalysisHome) } + case .view(.startTapped): return .run { [audioRecorderClient] send in let granted = await audioRecorderClient.requestPermission() diff --git a/Features/CryAnalysis/Home/CryAnalysisHomeView.swift b/Features/CryAnalysis/Home/CryAnalysisHomeView.swift index 55ac5551..6c736df8 100644 --- a/Features/CryAnalysis/Home/CryAnalysisHomeView.swift +++ b/Features/CryAnalysis/Home/CryAnalysisHomeView.swift @@ -22,6 +22,7 @@ public struct CryAnalysisHomeView: View { } } .alert($store.scope(state: \.alert, action: \.alert)) + .task { store.send(.view(.task)) } } private var introContent: some View { diff --git a/Features/Home/HomeFeature.swift b/Features/Home/HomeFeature.swift index 70f1cd92..3ac3b03a 100644 --- a/Features/Home/HomeFeature.swift +++ b/Features/Home/HomeFeature.swift @@ -60,6 +60,7 @@ public struct HomeFeature { case editRecord(PresentationAction) } + @Dependency(\.analytics) var analytics @Dependency(\.careRecordClient) var careRecordClient public init() {} @@ -68,7 +69,13 @@ public struct HomeFeature { BindingReducer() Reduce { state, action in switch action { - case .view(.task), .binding(\.selectedDate): + case .view(.task): + return .merge( + loadRecords(for: state), + .run { [analytics] _ in analytics.trackScreen(.home) } + ) + + case .binding(\.selectedDate): return loadRecords(for: state) case let .view(.careEntryTapped(entry)): @@ -140,7 +147,7 @@ public struct HomeFeature { of: state.selectedDate ) ?? now - return .run { [careRecordClient] send in + return .run { [analytics, careRecordClient] send in do throws(CareRecordError) { let event: CareEvent? = switch entry { case .feeding: @@ -171,6 +178,7 @@ public struct HomeFeature { guard let event else { return } try await careRecordClient.addRecord(babyID, CareRecord(createdAt: createdAt, event: event)) + analytics.track(.careRecordSaved(category: event.category)) await send(._internal(.recordAdded)) } catch { await send(._internal(.recordAddFailed(error))) diff --git a/Features/Login/LoginFeature.swift b/Features/Login/LoginFeature.swift index fba39073..f4bbaa6f 100644 --- a/Features/Login/LoginFeature.swift +++ b/Features/Login/LoginFeature.swift @@ -15,6 +15,7 @@ public struct LoginFeature { public enum Action { public enum ViewAction { + case task case signInWithAppleTapped case signInWithGoogleTapped } @@ -32,6 +33,7 @@ public struct LoginFeature { case alert(PresentationAction) } + @Dependency(\.analytics) var analytics @Dependency(\.authClient) var authClient public init() {} @@ -39,6 +41,9 @@ public struct LoginFeature { public var body: some ReducerOf { Reduce { state, action in switch action { + case .view(.task): + return .run { [analytics] _ in analytics.trackScreen(.login) } + case .view(.signInWithAppleTapped): state.isLoading = true return .run { [authClient] send in diff --git a/Features/Login/LoginView.swift b/Features/Login/LoginView.swift index 797f5a41..b7211b66 100644 --- a/Features/Login/LoginView.swift +++ b/Features/Login/LoginView.swift @@ -39,5 +39,6 @@ public struct LoginView: View { .background(Color.Semantic.launchBackground.ignoresSafeArea()) .loadingOverlay(isPresented: store.isLoading) .alert($store.scope(state: \.alert, action: \.alert)) + .task { store.send(.view(.task)) } } } diff --git a/Features/Note/Diary/DiaryEditorFeature.swift b/Features/Note/Diary/DiaryEditorFeature.swift index 34424f8a..98011259 100644 --- a/Features/Note/Diary/DiaryEditorFeature.swift +++ b/Features/Note/Diary/DiaryEditorFeature.swift @@ -47,6 +47,7 @@ public struct DiaryEditorFeature { public enum Action: BindableAction { public enum ViewAction: Equatable { + case task case removePhotoButtonTapped(DiaryPhoto.ID) case cancelButtonTapped case saveButtonTapped @@ -71,6 +72,7 @@ public struct DiaryEditorFeature { case binding(BindingAction) } + @Dependency(\.analytics) var analytics @Dependency(\.noteClient) var noteClient @Dependency(\.storageClient) var storageClient @Dependency(\.authClient) var authClient @@ -114,6 +116,9 @@ public struct DiaryEditorFeature { state.photos.append(photo) return .none + case .view(.task): + return .run { [analytics] _ in analytics.trackScreen(.noteEditor) } + case let .view(.removePhotoButtonTapped(id)): state.photos.removeAll { $0.id == id } state.pickerItems.removeAll { $0.itemIdentifier == id } @@ -138,7 +143,7 @@ public struct DiaryEditorFeature { let date = state.date let photoDatas = state.photos.map(\.data) AppLogger.info("save start noteID=\(noteID) creatorID=\(creatorID) photos=\(photoDatas.count)") - return .run { [storageClient, noteClient, babyID = state.babyID] send in + return .run { [analytics, storageClient, noteClient, babyID = state.babyID] send in let imageURLs: [URL] do throws(StorageError) { imageURLs = try await uploadDiaryPhotos( @@ -178,6 +183,7 @@ public struct DiaryEditorFeature { return } AppLogger.info("save completed noteID=\(noteID)") + analytics.track(.noteCreated(noteID: noteID)) await send(._internal(.saveCompleted)) } diff --git a/Features/Note/Diary/DiaryEditorView.swift b/Features/Note/Diary/DiaryEditorView.swift index 79b6d9c5..b39f8894 100644 --- a/Features/Note/Diary/DiaryEditorView.swift +++ b/Features/Note/Diary/DiaryEditorView.swift @@ -36,6 +36,7 @@ public struct DiaryEditorView: View { .disabled(!store.canSave) } } + .task { store.send(.view(.task)) } } } diff --git a/Features/Note/Home/NoteHomeFeature.swift b/Features/Note/Home/NoteHomeFeature.swift index 7037025f..cf67401c 100644 --- a/Features/Note/Home/NoteHomeFeature.swift +++ b/Features/Note/Home/NoteHomeFeature.swift @@ -63,6 +63,7 @@ public struct NoteHomeFeature { private enum CancelID { case notesStream } + @Dependency(\.analytics) var analytics @Dependency(\.noteClient) var noteClient public init() {} @@ -71,7 +72,13 @@ public struct NoteHomeFeature { BindingReducer() Reduce { state, action in switch action { - case .view(.task), .binding(\.month): + case .view(.task): + return .merge( + streamEffect(state: state), + .run { [analytics] _ in analytics.trackScreen(.noteList) } + ) + + case .binding(\.month): return streamEffect(state: state) case .view(.diaryButtonTapped): diff --git a/Features/Note/Schedule/ScheduleEditorFeature.swift b/Features/Note/Schedule/ScheduleEditorFeature.swift index bae916b5..59d31fd1 100644 --- a/Features/Note/Schedule/ScheduleEditorFeature.swift +++ b/Features/Note/Schedule/ScheduleEditorFeature.swift @@ -73,6 +73,7 @@ public struct ScheduleEditorFeature { case alert(PresentationAction) } + @Dependency(\.analytics) var analytics @Dependency(\.noteClient) var noteClient @Dependency(\.localNotificationClient) var localNotificationClient @Dependency(\.openURL) var openURL @@ -153,13 +154,14 @@ public struct ScheduleEditorFeature { date: state.date, reminder: reminder ) - return .run { [noteClient, babyID = state.babyID] send in + return .run { [analytics, noteClient, babyID = state.babyID] send in do throws(NoteError) { try await noteClient.addNote(babyID, note) } catch { await send(._internal(.saveFailed(error))) return } + analytics.track(.noteCreated(noteID: note.id)) await send(._internal(.saveCompleted)) } diff --git a/Features/Settings/Caregivers/CaregiverListFeature.swift b/Features/Settings/Caregivers/CaregiverListFeature.swift index 7025abd8..bb33ff10 100644 --- a/Features/Settings/Caregivers/CaregiverListFeature.swift +++ b/Features/Settings/Caregivers/CaregiverListFeature.swift @@ -43,6 +43,7 @@ public struct CaregiverListFeature { case alert(PresentationAction) } + @Dependency(\.analytics) var analytics @Dependency(\.caregiverClient) var caregiverClient @Dependency(\.babyClient) var babyClient @@ -56,12 +57,15 @@ public struct CaregiverListFeature { switch action { case .view(.task): let ids = state.baby.caregiverIDs - return .run { [caregiverClient] send in - for await caregivers in caregiverClient.streamCaregivers(ids) { - await send(._internal(.caregiversLoaded(caregivers))) + return .merge( + .run { [caregiverClient] send in + for await caregivers in caregiverClient.streamCaregivers(ids) { + await send(._internal(.caregiversLoaded(caregivers))) + } } - } - .cancellable(id: CancelID.stream, cancelInFlight: true) + .cancellable(id: CancelID.stream, cancelInFlight: true), + .run { [analytics] _ in analytics.trackScreen(.caregiverInvite) } + ) case let .view(.removeTapped(id)): guard state.isMainCaregiver, diff --git a/Features/Settings/SettingsFeature.swift b/Features/Settings/SettingsFeature.swift index f09c9e04..ca414f67 100644 --- a/Features/Settings/SettingsFeature.swift +++ b/Features/Settings/SettingsFeature.swift @@ -41,6 +41,7 @@ public struct SettingsFeature { public enum Action: BindableAction { public enum ViewAction { + case task case signOutTapped case deleteAccountTapped case signOutConfirmed @@ -69,6 +70,7 @@ public struct SettingsFeature { case path(StackActionOf) } + @Dependency(\.analytics) var analytics @Dependency(\.authClient) var authClient public init() {} @@ -80,6 +82,9 @@ public struct SettingsFeature { case .binding: return .none + case .view(.task): + return .run { [analytics] _ in analytics.trackScreen(.settings) } + case .view(.signOutTapped): state.alert = AlertState { TextState("๋กœ๊ทธ์•„์›ƒ") diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index bd28840d..2d99ec01 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -50,6 +50,7 @@ public struct SettingsView: View { CaregiverListView(store: store) } } + .task { store.send(.view(.task)) } } } diff --git a/Features/Statistic/StatisticFeature.swift b/Features/Statistic/StatisticFeature.swift index fab85ae7..d0f1c632 100644 --- a/Features/Statistic/StatisticFeature.swift +++ b/Features/Statistic/StatisticFeature.swift @@ -95,6 +95,7 @@ public struct StatisticFeature { case pdfPreview(PresentationAction) } + @Dependency(\.analytics) var analytics @Dependency(\.careRecordClient) var careRecordClient public init() {} @@ -104,12 +105,24 @@ public struct StatisticFeature { Reduce { state, action in switch action { case .view(.task): + let currentPeriod = period(for: state.mode) return .merge( loadRecords(for: state), - loadGrowthRecords(for: state) + loadGrowthRecords(for: state), + .run { [analytics] _ in + analytics.trackScreen(.statistic) + analytics.track(.statisticViewed(period: currentPeriod)) + } ) - case .binding(\.mode), .binding(\.selectedDate): + case .binding(\.mode): + let currentPeriod = period(for: state.mode) + return .merge( + loadRecords(for: state), + .run { [analytics] _ in analytics.track(.statisticViewed(period: currentPeriod)) } + ) + + case .binding(\.selectedDate): return loadRecords(for: state) case let ._internal(.recordsLoaded(records)): @@ -137,7 +150,7 @@ public struct StatisticFeature { records: state.records, date: state.selectedDate ) - return .none + return .run { [analytics] _ in analytics.track(.statisticPDFExported) } case .pdfPreview: return .none @@ -155,6 +168,13 @@ public struct StatisticFeature { } } + private func period(for mode: Mode) -> StatisticPeriod { + switch mode { + case .daily: .day + case .weekly: .week + } + } + private func loadGrowthRecords(for state: State) -> Effect { let babyID = state.baby.id let start = state.baby.birthDate