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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions Clients/AnalyticsClientLive/AnalyticsClient+Live.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 13 additions & 0 deletions Clients/AnalyticsClientLive/AnalyticsClient+Test.swift
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions Clients/Bootstrap.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Domain
@preconcurrency import FirebaseAnalytics
@preconcurrency import FirebaseAuth
import FirebaseCore
@preconcurrency import FirebaseCrashlytics
Expand Down Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions Domain/Analytics/AnalyticsClient.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
41 changes: 41 additions & 0 deletions Domain/Analytics/AnalyticsEnums.swift
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions Domain/Analytics/AnalyticsEvent.swift
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions Domain/Analytics/ScreenName.swift
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions Domain/Analytics/UserProperty.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

public enum UserProperty: Sendable, Equatable {
case babyCount(Int)
case hasCaregiver(Bool)
case babyAgeMonthsBucket(BabyAgeBucket)
case authProvider(AnalyticsAuthProvider)
}
7 changes: 7 additions & 0 deletions Features/BabyRegister/BabyRegisterFlowFeature.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ComposableArchitecture
import Domain
import Foundation

@Reducer
Expand All @@ -13,6 +14,7 @@ public struct BabyRegisterFlowFeature {

public enum Action {
public enum ViewAction {
case task
case cancelTapped
}

Expand All @@ -28,6 +30,8 @@ public struct BabyRegisterFlowFeature {
case path(StackActionOf<Path>)
}

@Dependency(\.analytics) var analytics

public init() {}

public var body: some ReducerOf<Self> {
Expand All @@ -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))

Expand Down
1 change: 1 addition & 0 deletions Features/BabyRegister/BabyRegisterFlowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ public struct BabyRegisterFlowView: View {
ExistingBabyRegisterView(store: existingStore)
}
}
.task { store.send(.view(.task)) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public struct ExistingBabyRegisterFeature {
case alert(PresentationAction<Alert>)
}

@Dependency(\.analytics) var analytics
@Dependency(\.babyClient) var babyClient

public init() {}
Expand All @@ -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))
Expand All @@ -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
Expand Down
76 changes: 45 additions & 31 deletions Features/CryAnalysis/Analysis/CryAnalysisFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
)
}
}
}
Expand Down
Loading
Loading