Replace EventLogger with typed AnalyticsManager using PostHog
Complete analytics overhaul: delete EventLogger.swift, create Analytics.swift with typed event enum (~45 events), screen tracking, super properties (theme, icon pack, voting layout, etc.), session replay with kill switch, autocapture, and network telemetry. Replace all 99 call sites across 38 files with compiler-enforced typed events in object_action naming convention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
1CD90B56278C7E7A001C4FEA /* FeelsWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; };
|
||||
1CD90B6E278C7F8B001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; };
|
||||
1CDE000F2F3BBD26006AE6A1 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 1CA00002300000000000002A /* PostHog */; };
|
||||
1CDEFBBF2F3B8736006AE6A1 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */; };
|
||||
1CDEFBC02F3B8736006AE6A1 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */; };
|
||||
46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */; };
|
||||
@@ -96,7 +97,6 @@
|
||||
membershipExceptions = (
|
||||
"Color+Codable.swift",
|
||||
"Date+Extensions.swift",
|
||||
EventLogger.swift,
|
||||
Models/DiamondView.swift,
|
||||
Models/Mood.swift,
|
||||
Models/MoodEntryModel.swift,
|
||||
@@ -147,6 +147,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1CDE000F2F3BBD26006AE6A1 /* PostHog in Frameworks */,
|
||||
1C9566442EF8F5F70032E68F /* Algorithms in Frameworks */,
|
||||
1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */,
|
||||
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */,
|
||||
|
||||
625
Shared/Analytics.swift
Normal file
625
Shared/Analytics.swift
Normal file
@@ -0,0 +1,625 @@
|
||||
//
|
||||
// Analytics.swift
|
||||
// Feels
|
||||
//
|
||||
// Singleton analytics manager wrapping PostHog SDK.
|
||||
// All analytics events flow through this single manager.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PostHog
|
||||
import UIKit
|
||||
|
||||
// MARK: - Analytics Manager
|
||||
|
||||
@MainActor
|
||||
final class AnalyticsManager {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = AnalyticsManager()
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private static let apiKey = "phc_3GsB3oqNft8Ykg2bJfE9MaJktzLAwr2EPMXQgwEFzAs"
|
||||
private static let host = "https://analytics.88oakapps.com"
|
||||
private static let optOutKey = "analyticsOptedOut"
|
||||
private static let sessionReplayKey = "analytics_session_replay_enabled"
|
||||
private static let iso8601Formatter = ISO8601DateFormatter()
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var isOptedOut: Bool {
|
||||
UserDefaults.standard.bool(forKey: Self.optOutKey)
|
||||
}
|
||||
|
||||
var sessionReplayEnabled: Bool {
|
||||
get {
|
||||
if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil {
|
||||
return true
|
||||
}
|
||||
return UserDefaults.standard.bool(forKey: Self.sessionReplayKey)
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: Self.sessionReplayKey)
|
||||
if newValue {
|
||||
PostHogSDK.shared.startSessionRecording()
|
||||
} else {
|
||||
PostHogSDK.shared.stopSessionRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isConfigured = false
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
func configure() {
|
||||
guard !isConfigured else { return }
|
||||
|
||||
let config = PostHogConfig(apiKey: Self.apiKey, host: Self.host)
|
||||
|
||||
// Auto-capture
|
||||
config.captureElementInteractions = true
|
||||
config.captureApplicationLifecycleEvents = true
|
||||
config.captureScreenViews = true
|
||||
|
||||
// Session replay
|
||||
config.sessionReplay = sessionReplayEnabled
|
||||
config.sessionReplayConfig.maskAllTextInputs = true
|
||||
config.sessionReplayConfig.maskAllImages = false
|
||||
config.sessionReplayConfig.captureNetworkTelemetry = true
|
||||
config.sessionReplayConfig.screenshotMode = true // Required for SwiftUI
|
||||
|
||||
// Respect user opt-out preference
|
||||
if isOptedOut {
|
||||
config.optOut = true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
config.debug = true
|
||||
config.flushAt = 1
|
||||
#endif
|
||||
|
||||
PostHogSDK.shared.setup(config)
|
||||
isConfigured = true
|
||||
|
||||
// Register super properties
|
||||
updateSuperProperties()
|
||||
}
|
||||
|
||||
// MARK: - Super Properties (attached to every event)
|
||||
|
||||
func updateSuperProperties() {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||
let device = UIDevice.current.model
|
||||
let osVersion = UIDevice.current.systemVersion
|
||||
|
||||
let defaults = GroupUserDefaults.groupDefaults
|
||||
var props: [String: Any] = [
|
||||
"app_version": version,
|
||||
"build_number": build,
|
||||
"device_model": device,
|
||||
"os_version": osVersion,
|
||||
"is_pro": IAPManager.shared.isSubscribed,
|
||||
"animations_enabled": !UIAccessibility.isReduceMotionEnabled,
|
||||
"selected_sports": [String](),
|
||||
"theme": "n/a",
|
||||
"icon_pack": "n/a",
|
||||
"voting_layout": "n/a",
|
||||
"day_view_style": "n/a",
|
||||
"mood_shape": "n/a",
|
||||
"personality_pack": "n/a",
|
||||
"privacy_lock_enabled": false,
|
||||
"healthkit_enabled": false,
|
||||
"days_filter_count": 0,
|
||||
"days_filter_all": false,
|
||||
]
|
||||
|
||||
// Theme
|
||||
if let themeRaw = defaults.string(forKey: UserDefaultsStore.Keys.theme.rawValue) {
|
||||
props["theme"] = themeRaw
|
||||
}
|
||||
|
||||
// Icon pack
|
||||
if let iconPackRaw = defaults.object(forKey: UserDefaultsStore.Keys.moodImages.rawValue) as? Int,
|
||||
let iconPack = MoodImages(rawValue: iconPackRaw) {
|
||||
props["icon_pack"] = String(describing: iconPack)
|
||||
}
|
||||
|
||||
// Voting layout
|
||||
if let layoutRaw = defaults.object(forKey: UserDefaultsStore.Keys.votingLayoutStyle.rawValue) as? Int,
|
||||
let layout = VotingLayoutStyle(rawValue: layoutRaw) {
|
||||
props["voting_layout"] = layout.displayName
|
||||
}
|
||||
|
||||
// Day view style
|
||||
if let styleRaw = defaults.object(forKey: UserDefaultsStore.Keys.dayViewStyle.rawValue) as? Int,
|
||||
let style = DayViewStyle(rawValue: styleRaw) {
|
||||
props["day_view_style"] = style.displayName
|
||||
}
|
||||
|
||||
// Shape
|
||||
if let shapeRaw = defaults.object(forKey: UserDefaultsStore.Keys.shape.rawValue) as? Int,
|
||||
let shape = BGShape(rawValue: shapeRaw) {
|
||||
props["mood_shape"] = String(describing: shape)
|
||||
}
|
||||
|
||||
// Personality pack
|
||||
if let packRaw = defaults.object(forKey: UserDefaultsStore.Keys.personalityPack.rawValue) as? Int,
|
||||
let pack = PersonalityPack(rawValue: packRaw) {
|
||||
props["personality_pack"] = pack.title()
|
||||
}
|
||||
|
||||
// Privacy lock
|
||||
props["privacy_lock_enabled"] = defaults.bool(forKey: UserDefaultsStore.Keys.privacyLockEnabled.rawValue)
|
||||
|
||||
// HealthKit
|
||||
props["healthkit_enabled"] = defaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||
|
||||
// Days filter
|
||||
let daysFilter = UserDefaultsStore.getDaysFilter()
|
||||
props["days_filter_count"] = daysFilter.count
|
||||
props["days_filter_all"] = daysFilter.count == 7
|
||||
|
||||
PostHogSDK.shared.register(props)
|
||||
}
|
||||
|
||||
func registerSuperProperties() {
|
||||
updateSuperProperties()
|
||||
}
|
||||
|
||||
// MARK: - Event Tracking
|
||||
|
||||
func track(_ event: Event) {
|
||||
guard isConfigured else { return }
|
||||
let (name, properties) = event.payload
|
||||
#if DEBUG
|
||||
print("[Analytics] \(name)", properties ?? [:])
|
||||
#endif
|
||||
PostHogSDK.shared.capture(name, properties: properties)
|
||||
}
|
||||
|
||||
// MARK: - Screen Tracking (manual supplement to auto-capture)
|
||||
|
||||
func trackScreen(_ screen: Screen, properties: [String: Any]? = nil) {
|
||||
guard isConfigured else { return }
|
||||
var props: [String: Any] = ["screen_name": screen.rawValue]
|
||||
if let properties { props.merge(properties) { _, new in new } }
|
||||
|
||||
#if DEBUG
|
||||
print("[Analytics] screen_viewed: \(screen.rawValue)")
|
||||
#endif
|
||||
PostHogSDK.shared.capture("screen_viewed", properties: props)
|
||||
}
|
||||
|
||||
// MARK: - Subscription Funnel
|
||||
|
||||
func trackPaywallViewed(source: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("paywall_viewed", properties: [
|
||||
"source": source
|
||||
])
|
||||
}
|
||||
|
||||
func trackPurchaseStarted(productId: String, source: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("purchase_started", properties: [
|
||||
"product_id": productId,
|
||||
"source": source
|
||||
])
|
||||
}
|
||||
|
||||
func trackPurchaseCompleted(productId: String, source: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("purchase_completed", properties: [
|
||||
"product_id": productId,
|
||||
"source": source
|
||||
])
|
||||
}
|
||||
|
||||
func trackPurchaseFailed(productId: String?, source: String, error: String) {
|
||||
guard isConfigured else { return }
|
||||
var props: [String: Any] = [
|
||||
"source": source,
|
||||
"error": error
|
||||
]
|
||||
if let productId {
|
||||
props["product_id"] = productId
|
||||
}
|
||||
PostHogSDK.shared.capture("purchase_failed", properties: props)
|
||||
}
|
||||
|
||||
func trackPurchaseRestored(source: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("purchase_restored", properties: [
|
||||
"source": source
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Opt In / Opt Out
|
||||
|
||||
func optIn() {
|
||||
UserDefaults.standard.set(false, forKey: Self.optOutKey)
|
||||
if isConfigured {
|
||||
PostHogSDK.shared.optIn()
|
||||
PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": true])
|
||||
}
|
||||
}
|
||||
|
||||
func optOut() {
|
||||
if isConfigured {
|
||||
PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": false])
|
||||
}
|
||||
UserDefaults.standard.set(true, forKey: Self.optOutKey)
|
||||
if isConfigured {
|
||||
PostHogSDK.shared.optOut()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Person Properties (subscription segmentation)
|
||||
|
||||
func updateSubscriptionStatus(_ status: String, type: String) {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.capture("$set", properties: [
|
||||
"$set": [
|
||||
"subscription_status": status,
|
||||
"subscription_type": type
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
func trackSubscriptionStatusObserved(
|
||||
status: String,
|
||||
type: String,
|
||||
source: String,
|
||||
isSubscribed: Bool,
|
||||
hasFullAccess: Bool,
|
||||
productId: String?,
|
||||
willAutoRenew: Bool?,
|
||||
isInGracePeriod: Bool?,
|
||||
trialDaysRemaining: Int?,
|
||||
expirationDate: Date?
|
||||
) {
|
||||
guard isConfigured else { return }
|
||||
|
||||
var props: [String: Any] = [
|
||||
"status": status,
|
||||
"type": type,
|
||||
"source": source,
|
||||
"is_subscribed": isSubscribed,
|
||||
"has_full_access": hasFullAccess
|
||||
]
|
||||
|
||||
if let productId {
|
||||
props["product_id"] = productId
|
||||
}
|
||||
if let willAutoRenew {
|
||||
props["will_auto_renew"] = willAutoRenew
|
||||
}
|
||||
if let isInGracePeriod {
|
||||
props["is_in_grace_period"] = isInGracePeriod
|
||||
}
|
||||
if let trialDaysRemaining {
|
||||
props["trial_days_remaining"] = trialDaysRemaining
|
||||
}
|
||||
if let expirationDate {
|
||||
props["expiration_date"] = Self.iso8601Formatter.string(from: expirationDate)
|
||||
}
|
||||
|
||||
PostHogSDK.shared.capture("subscription_status_changed", properties: props)
|
||||
updateSubscriptionStatus(status, type: type)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func flush() {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.flush()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
guard isConfigured else { return }
|
||||
PostHogSDK.shared.reset()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Screen Names
|
||||
|
||||
extension AnalyticsManager {
|
||||
enum Screen: String {
|
||||
case day = "day"
|
||||
case month = "month"
|
||||
case monthDetail = "month_detail"
|
||||
case year = "year"
|
||||
case insights = "insights"
|
||||
case settings = "settings"
|
||||
case customize = "customize"
|
||||
case onboarding = "onboarding"
|
||||
case onboardingSubscription = "onboarding_subscription"
|
||||
case paywall = "paywall"
|
||||
case entryDetail = "entry_detail"
|
||||
case noteEditor = "note_editor"
|
||||
case lockScreen = "lock_screen"
|
||||
case sharing = "sharing"
|
||||
case themePicker = "theme_picker"
|
||||
case iconPackPicker = "icon_pack_picker"
|
||||
case votingLayoutPicker = "voting_layout_picker"
|
||||
case dayStylePicker = "day_style_picker"
|
||||
case shapePicker = "shape_picker"
|
||||
case personalityPackPicker = "personality_pack_picker"
|
||||
case appThemePicker = "app_theme_picker"
|
||||
case dayFilterPicker = "day_filter_picker"
|
||||
case iconPicker = "icon_picker"
|
||||
case widgetCreator = "widget_creator"
|
||||
case widgetPicker = "widget_picker"
|
||||
case privacy = "privacy"
|
||||
case eula = "eula"
|
||||
case specialThanks = "special_thanks"
|
||||
case reminderTimePicker = "reminder_time_picker"
|
||||
case exportView = "export_view"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Definitions
|
||||
|
||||
extension AnalyticsManager {
|
||||
enum Event {
|
||||
// MARK: Mood
|
||||
case moodLogged(mood: Int, entryType: String)
|
||||
case moodUpdated(mood: Int)
|
||||
case noteUpdated(characterCount: Int)
|
||||
case photoAdded
|
||||
case photoDeleted
|
||||
case missingEntriesFilled(count: Int)
|
||||
|
||||
// MARK: Customization
|
||||
case themeChanged(themeId: String)
|
||||
case themeApplied(themeName: String)
|
||||
case iconPackChanged(packId: Int)
|
||||
case votingLayoutChanged(layout: String)
|
||||
case dayViewStyleChanged(style: String)
|
||||
case moodShapeChanged(shapeId: Int)
|
||||
case personalityPackChanged(packTitle: String)
|
||||
case appIconChanged(iconTitle: String)
|
||||
|
||||
// MARK: Widget
|
||||
case widgetViewed
|
||||
case widgetCreateTapped
|
||||
case widgetCreated
|
||||
case widgetUsed
|
||||
case widgetDeleted
|
||||
case widgetShuffled
|
||||
case widgetRandomized
|
||||
case widgetEyeUpdated(style: String)
|
||||
case widgetMouthUpdated(style: String)
|
||||
case widgetBackgroundUpdated(style: String)
|
||||
case widgetColorUpdated(part: String)
|
||||
|
||||
// MARK: Notifications
|
||||
case notificationEnabled
|
||||
case notificationDisabled
|
||||
case reminderTimeUpdated
|
||||
case reminderTimeTapped
|
||||
|
||||
// MARK: Onboarding
|
||||
case onboardingCompleted(dayId: String?)
|
||||
case onboardingSubscribeTapped
|
||||
case onboardingSkipped
|
||||
|
||||
// MARK: Subscription
|
||||
case paywallSubscribeTapped(source: String)
|
||||
|
||||
// MARK: Settings
|
||||
case settingsClosed
|
||||
case deleteToggleChanged(enabled: Bool)
|
||||
case exportTapped
|
||||
case dataExported(format: String, count: Int)
|
||||
case importTapped
|
||||
case importSucceeded
|
||||
case importFailed(error: String?)
|
||||
case onboardingReshown
|
||||
|
||||
// MARK: Privacy & Security
|
||||
case privacyLockEnabled
|
||||
case privacyLockDisabled
|
||||
case privacyLockEnableFailed
|
||||
case appLocked
|
||||
case biometricUnlockSuccess
|
||||
case biometricUnlockFailed(error: String)
|
||||
|
||||
// MARK: HealthKit
|
||||
case healthKitAuthorized
|
||||
case healthKitAuthFailed(error: String)
|
||||
case healthKitEnableFailed
|
||||
case healthKitDisabled
|
||||
case healthKitNotAuthorized
|
||||
case healthKitSyncCompleted(total: Int, success: Int, failed: Int)
|
||||
|
||||
// MARK: Navigation
|
||||
case tabSwitched(tab: String)
|
||||
case viewHeaderChanged(header: String)
|
||||
|
||||
// MARK: Sharing
|
||||
case shareTemplateViewed(template: String)
|
||||
|
||||
// MARK: Legal
|
||||
case eulaViewed
|
||||
case privacyPolicyViewed
|
||||
case specialThanksViewed
|
||||
|
||||
var payload: (name: String, properties: [String: Any]?) {
|
||||
switch self {
|
||||
// Mood
|
||||
case .moodLogged(let mood, let entryType):
|
||||
return ("mood_logged", ["mood_value": mood, "entry_type": entryType])
|
||||
case .moodUpdated(let mood):
|
||||
return ("mood_updated", ["mood_value": mood])
|
||||
case .noteUpdated(let count):
|
||||
return ("note_updated", ["character_count": count])
|
||||
case .photoAdded:
|
||||
return ("photo_added", nil)
|
||||
case .photoDeleted:
|
||||
return ("photo_deleted", nil)
|
||||
case .missingEntriesFilled(let count):
|
||||
return ("missing_entries_filled", ["count": count])
|
||||
|
||||
// Customization
|
||||
case .themeChanged(let id):
|
||||
return ("theme_changed", ["theme_id": id])
|
||||
case .themeApplied(let name):
|
||||
return ("theme_applied", ["theme_name": name])
|
||||
case .iconPackChanged(let id):
|
||||
return ("icon_pack_changed", ["pack_id": id])
|
||||
case .votingLayoutChanged(let layout):
|
||||
return ("voting_layout_changed", ["layout": layout])
|
||||
case .dayViewStyleChanged(let style):
|
||||
return ("day_view_style_changed", ["style": style])
|
||||
case .moodShapeChanged(let id):
|
||||
return ("mood_shape_changed", ["shape_id": id])
|
||||
case .personalityPackChanged(let title):
|
||||
return ("personality_pack_changed", ["pack_title": title])
|
||||
case .appIconChanged(let title):
|
||||
return ("app_icon_changed", ["icon_title": title])
|
||||
|
||||
// Widget
|
||||
case .widgetViewed:
|
||||
return ("widget_viewed", nil)
|
||||
case .widgetCreateTapped:
|
||||
return ("widget_create_tapped", nil)
|
||||
case .widgetCreated:
|
||||
return ("widget_created", nil)
|
||||
case .widgetUsed:
|
||||
return ("widget_used", nil)
|
||||
case .widgetDeleted:
|
||||
return ("widget_deleted", nil)
|
||||
case .widgetShuffled:
|
||||
return ("widget_shuffled", nil)
|
||||
case .widgetRandomized:
|
||||
return ("widget_randomized", nil)
|
||||
case .widgetEyeUpdated(let style):
|
||||
return ("widget_eye_updated", ["style": style])
|
||||
case .widgetMouthUpdated(let style):
|
||||
return ("widget_mouth_updated", ["style": style])
|
||||
case .widgetBackgroundUpdated(let style):
|
||||
return ("widget_background_updated", ["style": style])
|
||||
case .widgetColorUpdated(let part):
|
||||
return ("widget_color_updated", ["part": part])
|
||||
|
||||
// Notifications
|
||||
case .notificationEnabled:
|
||||
return ("notification_enabled", nil)
|
||||
case .notificationDisabled:
|
||||
return ("notification_disabled", nil)
|
||||
case .reminderTimeUpdated:
|
||||
return ("reminder_time_updated", nil)
|
||||
case .reminderTimeTapped:
|
||||
return ("reminder_time_tapped", nil)
|
||||
|
||||
// Onboarding
|
||||
case .onboardingCompleted(let dayId):
|
||||
var props: [String: Any] = [:]
|
||||
if let dayId { props["day_id"] = dayId }
|
||||
return ("onboarding_completed", props.isEmpty ? nil : props)
|
||||
case .onboardingSubscribeTapped:
|
||||
return ("onboarding_subscribe_tapped", nil)
|
||||
case .onboardingSkipped:
|
||||
return ("onboarding_skipped", nil)
|
||||
|
||||
// Subscription
|
||||
case .paywallSubscribeTapped(let source):
|
||||
return ("paywall_subscribe_tapped", ["source": source])
|
||||
|
||||
// Settings
|
||||
case .settingsClosed:
|
||||
return ("settings_closed", nil)
|
||||
case .deleteToggleChanged(let enabled):
|
||||
return ("delete_toggle_changed", ["enabled": enabled])
|
||||
case .exportTapped:
|
||||
return ("export_tapped", nil)
|
||||
case .dataExported(let format, let count):
|
||||
return ("data_exported", ["format": format, "count": count])
|
||||
case .importTapped:
|
||||
return ("import_tapped", nil)
|
||||
case .importSucceeded:
|
||||
return ("import_succeeded", nil)
|
||||
case .importFailed(let error):
|
||||
return ("import_failed", error != nil ? ["error": error!] : nil)
|
||||
case .onboardingReshown:
|
||||
return ("onboarding_reshown", nil)
|
||||
|
||||
// Privacy & Security
|
||||
case .privacyLockEnabled:
|
||||
return ("privacy_lock_enabled", nil)
|
||||
case .privacyLockDisabled:
|
||||
return ("privacy_lock_disabled", nil)
|
||||
case .privacyLockEnableFailed:
|
||||
return ("privacy_lock_enable_failed", nil)
|
||||
case .appLocked:
|
||||
return ("app_locked", nil)
|
||||
case .biometricUnlockSuccess:
|
||||
return ("biometric_unlock_success", nil)
|
||||
case .biometricUnlockFailed(let error):
|
||||
return ("biometric_unlock_failed", ["error": error])
|
||||
|
||||
// HealthKit
|
||||
case .healthKitAuthorized:
|
||||
return ("healthkit_authorized", nil)
|
||||
case .healthKitAuthFailed(let error):
|
||||
return ("healthkit_auth_failed", ["error": error])
|
||||
case .healthKitEnableFailed:
|
||||
return ("healthkit_enable_failed", nil)
|
||||
case .healthKitDisabled:
|
||||
return ("healthkit_disabled", nil)
|
||||
case .healthKitNotAuthorized:
|
||||
return ("healthkit_not_authorized", nil)
|
||||
case .healthKitSyncCompleted(let total, let success, let failed):
|
||||
return ("healthkit_sync_completed", ["total": total, "success": success, "failed": failed])
|
||||
|
||||
// Navigation
|
||||
case .tabSwitched(let tab):
|
||||
return ("tab_switched", ["tab": tab])
|
||||
case .viewHeaderChanged(let header):
|
||||
return ("view_header_changed", ["header": header])
|
||||
|
||||
// Sharing
|
||||
case .shareTemplateViewed(let template):
|
||||
return ("share_template_viewed", ["template": template])
|
||||
|
||||
// Legal
|
||||
case .eulaViewed:
|
||||
return ("eula_viewed", nil)
|
||||
case .privacyPolicyViewed:
|
||||
return ("privacy_policy_viewed", nil)
|
||||
case .specialThanksViewed:
|
||||
return ("special_thanks_viewed", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI Screen Tracking Modifier
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ScreenTrackingModifier: ViewModifier {
|
||||
let screen: AnalyticsManager.Screen
|
||||
let properties: [String: Any]?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.onAppear {
|
||||
AnalyticsManager.shared.trackScreen(screen, properties: properties)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func trackScreen(_ screen: AnalyticsManager.Screen, properties: [String: Any]? = nil) -> some View {
|
||||
modifier(ScreenTrackingModifier(screen: screen, properties: properties))
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
UITabBar.appearance().standardAppearance = appearance
|
||||
UITabBar.appearance().scrollEdgeAppearance = appearance
|
||||
|
||||
EventLogger.log(event: "app_launced")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// EventLogger.swift
|
||||
// Feels
|
||||
//
|
||||
// Created by Trey Tartt on 3/10/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if canImport(PostHog)
|
||||
import PostHog
|
||||
#endif
|
||||
|
||||
class EventLogger {
|
||||
static func log(event: String, withData data: [String: Any]? = nil) {
|
||||
#if DEBUG
|
||||
print("[EventLogger] \(event)", data ?? "")
|
||||
#endif
|
||||
#if canImport(PostHog)
|
||||
PostHogSDK.shared.capture(event, properties: data)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import BackgroundTasks
|
||||
import WidgetKit
|
||||
import PostHog
|
||||
|
||||
@main
|
||||
struct FeelsApp: App {
|
||||
@@ -24,12 +23,7 @@ struct FeelsApp: App {
|
||||
@State private var showSubscriptionFromWidget = false
|
||||
|
||||
init() {
|
||||
// Initialize PostHog analytics
|
||||
let posthogConfig = PostHogConfig(apiKey: "phc_3GsB3oqNft8Ykg2bJfE9MaJktzLAwr2EPMXQgwEFzAs", host: "https://analytics.88oakapps.com")
|
||||
#if DEBUG
|
||||
posthogConfig.debug = true
|
||||
#endif
|
||||
PostHogSDK.shared.setup(posthogConfig)
|
||||
AnalyticsManager.shared.configure()
|
||||
|
||||
BGTaskScheduler.shared.cancelAllTaskRequests()
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.updateDBMissingID, using: nil) { (task) in
|
||||
@@ -59,7 +53,7 @@ struct FeelsApp: App {
|
||||
.environmentObject(authManager)
|
||||
.environmentObject(healthKitManager)
|
||||
.sheet(isPresented: $showSubscriptionFromWidget) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "widget_deeplink")
|
||||
.environmentObject(iapManager)
|
||||
}
|
||||
.onOpenURL { url in
|
||||
@@ -78,6 +72,8 @@ struct FeelsApp: App {
|
||||
if newPhase == .background {
|
||||
BGTask.scheduleBackgroundProcessing()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
// Flush pending analytics events
|
||||
AnalyticsManager.shared.flush()
|
||||
// Lock the app when going to background
|
||||
authManager.lock()
|
||||
}
|
||||
@@ -106,8 +102,8 @@ struct FeelsApp: App {
|
||||
// Reschedule notifications for new title
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
|
||||
// Log event
|
||||
EventLogger.log(event: "app_foregorund")
|
||||
// Update super properties on foreground
|
||||
AnalyticsManager.shared.updateSuperProperties()
|
||||
}
|
||||
|
||||
// Defer Live Activity scheduling (heavy DB operations)
|
||||
@@ -123,36 +119,7 @@ struct FeelsApp: App {
|
||||
// Check subscription status (network call) - throttled
|
||||
Task.detached(priority: .background) {
|
||||
await iapManager.checkSubscriptionStatus()
|
||||
|
||||
// Set PostHog person properties for subscription segmentation
|
||||
let state = await iapManager.state
|
||||
let subscriptionStatus: String
|
||||
let subscriptionType: String
|
||||
|
||||
switch state {
|
||||
case .subscribed:
|
||||
subscriptionStatus = "subscribed"
|
||||
subscriptionType = await iapManager.currentProduct?.id.contains("yearly") == true ? "yearly" : "monthly"
|
||||
case .inTrial:
|
||||
subscriptionStatus = "trial"
|
||||
subscriptionType = "none"
|
||||
case .trialExpired:
|
||||
subscriptionStatus = "trial_expired"
|
||||
subscriptionType = "none"
|
||||
case .expired:
|
||||
subscriptionStatus = "expired"
|
||||
subscriptionType = "none"
|
||||
case .unknown:
|
||||
subscriptionStatus = "unknown"
|
||||
subscriptionType = "none"
|
||||
}
|
||||
|
||||
PostHogSDK.shared.capture("$set", properties: [
|
||||
"$set": [
|
||||
"subscription_status": subscriptionStatus,
|
||||
"subscription_type": subscriptionType
|
||||
]
|
||||
])
|
||||
await iapManager.trackSubscriptionAnalytics(source: "app_foreground")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,11 +254,7 @@ class HealthKitManager: ObservableObject {
|
||||
syncStatus = "Synced \(successCount), failed \(failCount)"
|
||||
}
|
||||
logger.info("HealthKit sync complete: \(successCount) succeeded, \(failCount) failed")
|
||||
EventLogger.log(event: "healthkit_sync_complete", withData: [
|
||||
"total": totalToSync,
|
||||
"success": successCount,
|
||||
"failed": failCount
|
||||
])
|
||||
AnalyticsManager.shared.track(.healthKitSyncCompleted(total: totalToSync, success: successCount, failed: failCount))
|
||||
}
|
||||
|
||||
// MARK: - Delete All Moods from HealthKit
|
||||
|
||||
@@ -15,9 +15,12 @@ import os.log
|
||||
enum SubscriptionState: Equatable {
|
||||
case unknown
|
||||
case subscribed(expirationDate: Date?, willAutoRenew: Bool)
|
||||
case billingRetry(expirationDate: Date?, willAutoRenew: Bool)
|
||||
case gracePeriod(expirationDate: Date?, willAutoRenew: Bool)
|
||||
case inTrial(daysRemaining: Int)
|
||||
case trialExpired
|
||||
case expired
|
||||
case revoked
|
||||
}
|
||||
|
||||
// MARK: - IAPManager
|
||||
@@ -84,16 +87,20 @@ class IAPManager: ObservableObject {
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var isSubscribed: Bool {
|
||||
if case .subscribed = state { return true }
|
||||
return false
|
||||
switch state {
|
||||
case .subscribed, .billingRetry, .gracePeriod:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var hasFullAccess: Bool {
|
||||
if Self.bypassSubscription { return true }
|
||||
switch state {
|
||||
case .subscribed, .inTrial:
|
||||
case .subscribed, .billingRetry, .gracePeriod, .inTrial:
|
||||
return true
|
||||
case .unknown, .trialExpired, .expired:
|
||||
case .unknown, .trialExpired, .expired, .revoked:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -101,9 +108,9 @@ class IAPManager: ObservableObject {
|
||||
var shouldShowPaywall: Bool {
|
||||
if Self.bypassSubscription { return false }
|
||||
switch state {
|
||||
case .trialExpired, .expired:
|
||||
case .trialExpired, .expired, .revoked:
|
||||
return true
|
||||
case .unknown, .subscribed, .inTrial:
|
||||
case .unknown, .subscribed, .billingRetry, .gracePeriod, .inTrial:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -169,10 +176,28 @@ class IAPManager: ObservableObject {
|
||||
|
||||
if hasActiveSubscription {
|
||||
// State already set in checkForActiveSubscription — cache it
|
||||
if case .subscribed(let expiration, _) = state {
|
||||
switch state {
|
||||
case .subscribed(let expiration, _),
|
||||
.billingRetry(let expiration, _),
|
||||
.gracePeriod(let expiration, _):
|
||||
cacheSubscriptionExpiration(expiration)
|
||||
default:
|
||||
break
|
||||
}
|
||||
syncSubscriptionStatusToUserDefaults()
|
||||
trackSubscriptionAnalytics(source: "status_check_active")
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve terminal StoreKit states (expired/revoked) instead of overriding with trial fallback.
|
||||
if case .expired = state {
|
||||
syncSubscriptionStatusToUserDefaults()
|
||||
trackSubscriptionAnalytics(source: "status_check_terminal")
|
||||
return
|
||||
}
|
||||
if case .revoked = state {
|
||||
syncSubscriptionStatusToUserDefaults()
|
||||
trackSubscriptionAnalytics(source: "status_check_terminal")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,12 +207,14 @@ class IAPManager: ObservableObject {
|
||||
if let expiration = cachedExpiration, expiration > Date() {
|
||||
state = .subscribed(expirationDate: expiration, willAutoRenew: false)
|
||||
syncSubscriptionStatusToUserDefaults()
|
||||
trackSubscriptionAnalytics(source: "status_check_cached")
|
||||
return
|
||||
}
|
||||
|
||||
// Subscription genuinely gone — clear cache and fall back to trial
|
||||
cacheSubscriptionExpiration(nil)
|
||||
updateTrialState()
|
||||
trackSubscriptionAnalytics(source: "status_check_fallback")
|
||||
}
|
||||
|
||||
/// Sync subscription status to UserDefaults for widget access
|
||||
@@ -218,11 +245,14 @@ class IAPManager: ObservableObject {
|
||||
}
|
||||
|
||||
/// Restore purchases
|
||||
func restore() async {
|
||||
func restore(source: String = "settings") async {
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
await checkSubscriptionStatus()
|
||||
AnalyticsManager.shared.trackPurchaseRestored(source: source)
|
||||
trackSubscriptionAnalytics(source: "restore")
|
||||
} catch {
|
||||
AnalyticsManager.shared.trackPurchaseFailed(productId: nil, source: source, error: error.localizedDescription)
|
||||
AppLogger.iap.error("Failed to restore purchases: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
@@ -239,6 +269,8 @@ class IAPManager: ObservableObject {
|
||||
}
|
||||
|
||||
private func checkForActiveSubscription() async -> Bool {
|
||||
var nonActiveState: SubscriptionState?
|
||||
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result else { continue }
|
||||
|
||||
@@ -255,23 +287,46 @@ class IAPManager: ObservableObject {
|
||||
if let product = currentProduct,
|
||||
let subscription = product.subscription,
|
||||
let statuses = try? await subscription.status {
|
||||
var hadVerifiedStatus = false
|
||||
|
||||
for status in statuses {
|
||||
guard case .verified(let renewalInfo) = status.renewalInfo else { continue }
|
||||
hadVerifiedStatus = true
|
||||
|
||||
switch status.state {
|
||||
case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
|
||||
case .subscribed:
|
||||
state = .subscribed(
|
||||
expirationDate: transaction.expirationDate,
|
||||
willAutoRenew: renewalInfo.willAutoRenew
|
||||
)
|
||||
return true
|
||||
case .expired, .revoked:
|
||||
case .inBillingRetryPeriod:
|
||||
state = .billingRetry(
|
||||
expirationDate: transaction.expirationDate,
|
||||
willAutoRenew: renewalInfo.willAutoRenew
|
||||
)
|
||||
return true
|
||||
case .inGracePeriod:
|
||||
state = .gracePeriod(
|
||||
expirationDate: transaction.expirationDate,
|
||||
willAutoRenew: renewalInfo.willAutoRenew
|
||||
)
|
||||
return true
|
||||
case .expired:
|
||||
nonActiveState = .expired
|
||||
continue
|
||||
case .revoked:
|
||||
nonActiveState = .revoked
|
||||
continue
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// We had detailed status and none were active, so do not fallback to subscribed.
|
||||
if hadVerifiedStatus {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback if we couldn't get detailed status
|
||||
@@ -280,7 +335,11 @@ class IAPManager: ObservableObject {
|
||||
}
|
||||
|
||||
// No active subscription found
|
||||
currentProduct = nil
|
||||
if let nonActiveState {
|
||||
state = nonActiveState
|
||||
} else {
|
||||
currentProduct = nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -297,6 +356,112 @@ class IAPManager: ObservableObject {
|
||||
syncSubscriptionStatusToUserDefaults()
|
||||
}
|
||||
|
||||
// MARK: - Analytics
|
||||
|
||||
func trackSubscriptionAnalytics(source: String) {
|
||||
let status: String
|
||||
let isSubscribed: Bool
|
||||
let hasFullAccess: Bool
|
||||
let productId = currentProduct?.id
|
||||
let type = subscriptionType(for: productId)
|
||||
let willAutoRenew: Bool?
|
||||
let isInGracePeriod: Bool?
|
||||
let trialDaysRemaining: Int?
|
||||
let expirationDate: Date?
|
||||
|
||||
switch state {
|
||||
case .unknown:
|
||||
status = "unknown"
|
||||
isSubscribed = false
|
||||
hasFullAccess = Self.bypassSubscription
|
||||
willAutoRenew = nil
|
||||
isInGracePeriod = nil
|
||||
trialDaysRemaining = nil
|
||||
expirationDate = nil
|
||||
case .subscribed(let expiration, let autoRenew):
|
||||
status = "subscribed"
|
||||
isSubscribed = true
|
||||
hasFullAccess = true
|
||||
willAutoRenew = autoRenew
|
||||
isInGracePeriod = false
|
||||
trialDaysRemaining = nil
|
||||
expirationDate = expiration
|
||||
case .billingRetry(let expiration, let autoRenew):
|
||||
status = "billing_retry"
|
||||
isSubscribed = true
|
||||
hasFullAccess = true
|
||||
willAutoRenew = autoRenew
|
||||
isInGracePeriod = true
|
||||
trialDaysRemaining = nil
|
||||
expirationDate = expiration
|
||||
case .gracePeriod(let expiration, let autoRenew):
|
||||
status = "grace_period"
|
||||
isSubscribed = true
|
||||
hasFullAccess = true
|
||||
willAutoRenew = autoRenew
|
||||
isInGracePeriod = true
|
||||
trialDaysRemaining = nil
|
||||
expirationDate = expiration
|
||||
case .inTrial(let daysRemaining):
|
||||
status = "trial"
|
||||
isSubscribed = false
|
||||
hasFullAccess = true
|
||||
willAutoRenew = nil
|
||||
isInGracePeriod = nil
|
||||
trialDaysRemaining = daysRemaining
|
||||
expirationDate = nil
|
||||
case .trialExpired:
|
||||
status = "trial_expired"
|
||||
isSubscribed = false
|
||||
hasFullAccess = false
|
||||
willAutoRenew = nil
|
||||
isInGracePeriod = nil
|
||||
trialDaysRemaining = nil
|
||||
expirationDate = nil
|
||||
case .expired:
|
||||
status = "expired"
|
||||
isSubscribed = false
|
||||
hasFullAccess = false
|
||||
willAutoRenew = nil
|
||||
isInGracePeriod = nil
|
||||
trialDaysRemaining = nil
|
||||
expirationDate = nil
|
||||
case .revoked:
|
||||
status = "revoked"
|
||||
isSubscribed = false
|
||||
hasFullAccess = false
|
||||
willAutoRenew = nil
|
||||
isInGracePeriod = nil
|
||||
trialDaysRemaining = nil
|
||||
expirationDate = nil
|
||||
}
|
||||
|
||||
AnalyticsManager.shared.trackSubscriptionStatusObserved(
|
||||
status: status,
|
||||
type: type,
|
||||
source: source,
|
||||
isSubscribed: isSubscribed,
|
||||
hasFullAccess: hasFullAccess,
|
||||
productId: productId,
|
||||
willAutoRenew: willAutoRenew,
|
||||
isInGracePeriod: isInGracePeriod,
|
||||
trialDaysRemaining: trialDaysRemaining,
|
||||
expirationDate: expirationDate
|
||||
)
|
||||
}
|
||||
|
||||
private func subscriptionType(for productID: String?) -> String {
|
||||
guard let productID else { return "none" }
|
||||
let id = productID.lowercased()
|
||||
if id.contains("annual") || id.contains("year") {
|
||||
return "yearly"
|
||||
}
|
||||
if id.contains("month") {
|
||||
return "monthly"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
private func listenForTransactions() -> Task<Void, Error> {
|
||||
Task.detached { [weak self] in
|
||||
for await result in Transaction.updates {
|
||||
|
||||
@@ -21,10 +21,10 @@ class LocalNotification {
|
||||
public class func testIfEnabled(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { success, error in
|
||||
if success {
|
||||
EventLogger.log(event: "local_notification_enabled")
|
||||
AnalyticsManager.shared.track(.notificationEnabled)
|
||||
completion(.success(true))
|
||||
} else if let error = error {
|
||||
EventLogger.log(event: "local_notification_disabled")
|
||||
AnalyticsManager.shared.track(.notificationDisabled)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,9 @@ enum AppTheme: Int, CaseIterable, Identifiable {
|
||||
GroupUserDefaults.groupDefaults.set(lockScreenStyle.rawValue, forKey: UserDefaultsStore.Keys.lockScreenStyle.rawValue)
|
||||
|
||||
// Log the theme change
|
||||
EventLogger.log(event: "apply_theme", withData: ["theme": name])
|
||||
Task { @MainActor in
|
||||
AnalyticsManager.shared.track(.themeApplied(themeName: name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ struct OnboardingSubscription: View {
|
||||
VStack(spacing: 12) {
|
||||
// Subscribe button
|
||||
Button(action: {
|
||||
EventLogger.log(event: "onboarding_subscribe_tapped")
|
||||
AnalyticsManager.shared.track(.onboardingSubscribeTapped)
|
||||
showSubscriptionStore = true
|
||||
}) {
|
||||
HStack {
|
||||
@@ -120,8 +120,8 @@ struct OnboardingSubscription: View {
|
||||
|
||||
// Skip button
|
||||
Button(action: {
|
||||
EventLogger.log(event: "onboarding_complete")
|
||||
EventLogger.log(event: "onboarding_skip_subscription")
|
||||
AnalyticsManager.shared.track(.onboardingCompleted(dayId: nil))
|
||||
AnalyticsManager.shared.track(.onboardingSkipped)
|
||||
completionClosure(onboardingData)
|
||||
}) {
|
||||
Text("Maybe Later")
|
||||
@@ -138,10 +138,10 @@ struct OnboardingSubscription: View {
|
||||
}
|
||||
.sheet(isPresented: $showSubscriptionStore, onDismiss: {
|
||||
// After subscription store closes, complete onboarding
|
||||
EventLogger.log(event: "onboarding_complete")
|
||||
AnalyticsManager.shared.track(.onboardingCompleted(dayId: nil))
|
||||
completionClosure(onboardingData)
|
||||
}) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "onboarding")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,7 @@ struct OnboardingWrapup: View {
|
||||
.foregroundColor(Color(UIColor.white))
|
||||
|
||||
Button(action: {
|
||||
EventLogger.log(event: "onboarding_complete")
|
||||
EventLogger.log(event: "onboarding_complete_day_id",
|
||||
withData: ["id": onboardingData.inputDay.rawValue])
|
||||
AnalyticsManager.shared.track(.onboardingCompleted(dayId: String(onboardingData.inputDay.rawValue)))
|
||||
completionClosure(onboardingData)
|
||||
}, label: {
|
||||
Text(String(localized: "onboarding_wrap_up_complete_button"))
|
||||
|
||||
@@ -26,7 +26,7 @@ extension DataController {
|
||||
)
|
||||
|
||||
modelContext.insert(entry)
|
||||
EventLogger.log(event: "add_entry", withData: ["entry_type": entryType.rawValue])
|
||||
AnalyticsManager.shared.track(.moodLogged(mood: mood.rawValue, entryType: String(describing: entryType)))
|
||||
saveAndRunDataListeners()
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ extension DataController {
|
||||
|
||||
// Single save and listener notification at the end
|
||||
saveAndRunDataListeners()
|
||||
EventLogger.log(event: "filled_in_missing_entries", withData: ["count": missing.count])
|
||||
AnalyticsManager.shared.track(.missingEntriesFilled(count: missing.count))
|
||||
}
|
||||
|
||||
func fixWrongWeekdays() {
|
||||
@@ -83,6 +83,5 @@ extension DataController {
|
||||
func removeNoForDates() {
|
||||
// Note: With SwiftData's non-optional forDate, this is essentially a no-op
|
||||
// Keeping for API compatibility
|
||||
EventLogger.log(event: "removed_entry_no_for_date", withData: ["count": 0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ extension DataController {
|
||||
entry.moodValue = mood.rawValue
|
||||
saveAndRunDataListeners()
|
||||
|
||||
EventLogger.log(event: "update_entry")
|
||||
AnalyticsManager.shared.track(.moodUpdated(mood: mood.rawValue))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ extension DataController {
|
||||
entry.notes = notes
|
||||
saveAndRunDataListeners()
|
||||
|
||||
EventLogger.log(event: "update_notes")
|
||||
AnalyticsManager.shared.track(.noteUpdated(characterCount: (notes ?? "").count))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ extension DataController {
|
||||
entry.photoID = photoID
|
||||
saveAndRunDataListeners()
|
||||
|
||||
EventLogger.log(event: "update_photo")
|
||||
if photoID != nil {
|
||||
AnalyticsManager.shared.track(.photoAdded)
|
||||
} else {
|
||||
AnalyticsManager.shared.track(.photoDeleted)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +98,12 @@ class BiometricAuthManager: ObservableObject {
|
||||
|
||||
isUnlocked = success
|
||||
if success {
|
||||
EventLogger.log(event: "biometric_unlock_success")
|
||||
AnalyticsManager.shared.track(.biometricUnlockSuccess)
|
||||
}
|
||||
return success
|
||||
} catch {
|
||||
print("Authentication failed: \(error.localizedDescription)")
|
||||
EventLogger.log(event: "biometric_unlock_failed", withData: ["error": error.localizedDescription])
|
||||
AnalyticsManager.shared.track(.biometricUnlockFailed(error: error.localizedDescription))
|
||||
|
||||
// If biometrics failed, try device passcode as fallback
|
||||
if canUseDevicePasscode && policy == .deviceOwnerAuthenticationWithBiometrics {
|
||||
@@ -136,7 +136,7 @@ class BiometricAuthManager: ObservableObject {
|
||||
func lock() {
|
||||
guard isLockEnabled else { return }
|
||||
isUnlocked = false
|
||||
EventLogger.log(event: "app_locked")
|
||||
AnalyticsManager.shared.track(.appLocked)
|
||||
}
|
||||
|
||||
func enableLock() async -> Bool {
|
||||
@@ -159,7 +159,7 @@ class BiometricAuthManager: ObservableObject {
|
||||
if success {
|
||||
isLockEnabled = true
|
||||
isUnlocked = true
|
||||
EventLogger.log(event: "privacy_lock_enabled")
|
||||
AnalyticsManager.shared.track(.privacyLockEnabled)
|
||||
}
|
||||
|
||||
return success
|
||||
@@ -172,6 +172,6 @@ class BiometricAuthManager: ObservableObject {
|
||||
func disableLock() {
|
||||
isLockEnabled = false
|
||||
isUnlocked = true
|
||||
EventLogger.log(event: "privacy_lock_disabled")
|
||||
AnalyticsManager.shared.track(.privacyLockDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ class ExportService {
|
||||
.horrible: UIColor(red: 0.91, green: 0.30, blue: 0.24, alpha: 1.0) // Red
|
||||
]
|
||||
|
||||
private func trackDataExported(format: String, count: Int) {
|
||||
Task { @MainActor in
|
||||
AnalyticsManager.shared.track(.dataExported(format: format, count: count))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CSV Export
|
||||
|
||||
func generateCSV(entries: [MoodEntryModel]) -> String {
|
||||
@@ -75,7 +81,7 @@ class ExportService {
|
||||
|
||||
do {
|
||||
try csv.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||
EventLogger.log(event: "csv_exported", withData: ["count": entries.count])
|
||||
trackDataExported(format: "csv", count: entries.count)
|
||||
return tempURL
|
||||
} catch {
|
||||
print("ExportService: Failed to write CSV: \(error)")
|
||||
@@ -152,7 +158,7 @@ class ExportService {
|
||||
drawFooter(pageWidth: pageWidth, pageHeight: pageHeight, margin: margin, in: context)
|
||||
}
|
||||
|
||||
EventLogger.log(event: "pdf_exported", withData: ["count": entries.count])
|
||||
trackDataExported(format: "pdf", count: entries.count)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -79,11 +79,11 @@ class HealthService: ObservableObject {
|
||||
try await healthStore.requestAuthorization(toShare: [], read: readTypes)
|
||||
isAuthorized = true
|
||||
isEnabled = true
|
||||
EventLogger.log(event: "healthkit_authorized")
|
||||
AnalyticsManager.shared.track(.healthKitAuthorized)
|
||||
return true
|
||||
} catch {
|
||||
print("HealthService: Authorization failed: \(error.localizedDescription)")
|
||||
EventLogger.log(event: "healthkit_auth_failed", withData: ["error": error.localizedDescription])
|
||||
AnalyticsManager.shared.track(.healthKitAuthFailed(error: error.localizedDescription))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class PhotoManager: ObservableObject {
|
||||
try? thumbnailData.write(to: thumbnailURL)
|
||||
}
|
||||
|
||||
EventLogger.log(event: "photo_saved")
|
||||
AnalyticsManager.shared.track(.photoAdded)
|
||||
return photoID
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ class PhotoManager: ObservableObject {
|
||||
}
|
||||
|
||||
if success {
|
||||
EventLogger.log(event: "photo_deleted")
|
||||
AnalyticsManager.shared.track(.photoDeleted)
|
||||
}
|
||||
|
||||
return success
|
||||
|
||||
@@ -46,8 +46,7 @@ struct CreateWidgetView: View {
|
||||
}
|
||||
|
||||
func update(eye: CustomWidgetEyes, eyeOption: CustomWidgeImageOptions) {
|
||||
EventLogger.log(event: "create_widget_view_update_eye",
|
||||
withData: ["eye_value": eye.rawValue, "eye_option_value": eyeOption.rawValue])
|
||||
AnalyticsManager.shared.track(.widgetEyeUpdated(style: eyeOption.rawValue))
|
||||
switch eye {
|
||||
case .left:
|
||||
customWidget.leftEye = eyeOption
|
||||
@@ -57,7 +56,7 @@ struct CreateWidgetView: View {
|
||||
}
|
||||
|
||||
func createRandom() {
|
||||
EventLogger.log(event: "create_widget_view_create_random")
|
||||
AnalyticsManager.shared.track(.widgetRandomized)
|
||||
customWidget.bgColor = Color.random()
|
||||
customWidget.innerColor = Color.random()
|
||||
customWidget.bgOverlayColor = Color.random()
|
||||
@@ -74,14 +73,12 @@ struct CreateWidgetView: View {
|
||||
}
|
||||
|
||||
func update(mouthOption: CustomWidgeImageOptions) {
|
||||
EventLogger.log(event: "create_widget_view_update_mouth",
|
||||
withData: ["mouthOption": mouthOption.rawValue])
|
||||
AnalyticsManager.shared.track(.widgetMouthUpdated(style: mouthOption.rawValue))
|
||||
customWidget.mouth = mouthOption
|
||||
}
|
||||
|
||||
func update(background: CustomWidgetBackGroundOptions) {
|
||||
EventLogger.log(event: "create_widget_view_update_background",
|
||||
withData: ["background": background.rawValue])
|
||||
AnalyticsManager.shared.track(.widgetBackgroundUpdated(style: background.rawValue))
|
||||
customWidget.background = background
|
||||
}
|
||||
|
||||
@@ -101,7 +98,7 @@ struct CreateWidgetView: View {
|
||||
var bottomBarButtons: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Button(action: {
|
||||
EventLogger.log(event: "create_widget_view_shuffle")
|
||||
AnalyticsManager.shared.track(.widgetShuffled)
|
||||
createRandom()
|
||||
}, label: {
|
||||
Image(systemName: "shuffle")
|
||||
@@ -114,7 +111,7 @@ struct CreateWidgetView: View {
|
||||
.background(.blue)
|
||||
|
||||
Button(action: {
|
||||
EventLogger.log(event: "create_widget_view_save_widget")
|
||||
AnalyticsManager.shared.track(.widgetCreated)
|
||||
UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: false)
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
@@ -132,7 +129,7 @@ struct CreateWidgetView: View {
|
||||
.background(.green)
|
||||
|
||||
Button(action: {
|
||||
EventLogger.log(event: "customize_view_use_widget")
|
||||
AnalyticsManager.shared.track(.widgetUsed)
|
||||
UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: true)
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
@@ -151,7 +148,7 @@ struct CreateWidgetView: View {
|
||||
|
||||
if customWidget.isSaved {
|
||||
Button(action: {
|
||||
EventLogger.log(event: "customize_view_delete_widget")
|
||||
AnalyticsManager.shared.track(.widgetDeleted)
|
||||
UserDefaultsStore.deleteCustomWidget(withUUID: customWidget.uuid)
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
@@ -178,7 +175,7 @@ struct CreateWidgetView: View {
|
||||
Text(String(localized: "create_widget_background_color"))
|
||||
ColorPicker("", selection: $customWidget.bgColor)
|
||||
.onChange(of: customWidget.bgColor) {
|
||||
EventLogger.log(event: "create_widget_view_update_background_color")
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "background"))
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
@@ -188,7 +185,7 @@ struct CreateWidgetView: View {
|
||||
Text(String(localized: "create_widget_inner_color"))
|
||||
ColorPicker("", selection: $customWidget.innerColor)
|
||||
.onChange(of: customWidget.innerColor) {
|
||||
EventLogger.log(event: "create_widget_view_update_inner_color")
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "inner"))
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
@@ -198,7 +195,7 @@ struct CreateWidgetView: View {
|
||||
Text(String(localized: "create_widget_face_outline_color"))
|
||||
ColorPicker("", selection: $customWidget.circleStrokeColor)
|
||||
.onChange(of: customWidget.circleStrokeColor) {
|
||||
EventLogger.log(event: "create_widget_view_update_outline_color")
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "outline"))
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
@@ -210,7 +207,7 @@ struct CreateWidgetView: View {
|
||||
Text(String(localized: "create_widget_view_left_eye_color"))
|
||||
ColorPicker("", selection: $customWidget.leftEyeColor)
|
||||
.onChange(of: customWidget.leftEyeColor) {
|
||||
EventLogger.log(event: "create_widget_view_update_left_eye_color")
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "left_eye"))
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
@@ -220,7 +217,7 @@ struct CreateWidgetView: View {
|
||||
Text(String(localized: "create_widget_view_right_eye_color"))
|
||||
ColorPicker("", selection: $customWidget.rightEyeColor)
|
||||
.onChange(of: customWidget.rightEyeColor) {
|
||||
EventLogger.log(event: "create_widget_view_update_right_eye_color")
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "right_eye"))
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
@@ -230,7 +227,7 @@ struct CreateWidgetView: View {
|
||||
Text(String(localized: "create_widget_view_mouth_color"))
|
||||
ColorPicker("", selection: $customWidget.mouthColor)
|
||||
.onChange(of: customWidget.mouthColor) {
|
||||
EventLogger.log(event: "create_widget_view_update_mouth_color")
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "mouth"))
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ struct CustomizeContentView: View {
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.onAppear(perform: {
|
||||
EventLogger.log(event: "show_customize_view")
|
||||
AnalyticsManager.shared.trackScreen(.customize)
|
||||
})
|
||||
.customizeLayoutTip()
|
||||
}
|
||||
@@ -175,10 +175,10 @@ struct CustomizeView: View {
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.onAppear(perform: {
|
||||
EventLogger.log(event: "show_customize_view")
|
||||
AnalyticsManager.shared.trackScreen(.customize)
|
||||
})
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "customize")
|
||||
}
|
||||
.background(
|
||||
theme.currentTheme.bg
|
||||
@@ -253,7 +253,7 @@ struct ThemePickerCompact: View {
|
||||
ForEach(Theme.allCases, id: \.rawValue) { aTheme in
|
||||
Button(action: {
|
||||
theme = aTheme
|
||||
EventLogger.log(event: "change_theme_id", withData: ["id": aTheme.rawValue])
|
||||
AnalyticsManager.shared.track(.themeChanged(themeId: aTheme.rawValue))
|
||||
}) {
|
||||
VStack(spacing: 8) {
|
||||
ZStack {
|
||||
@@ -300,7 +300,7 @@ struct ImagePackPickerCompact: View {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .medium)
|
||||
impactMed.impactOccurred()
|
||||
imagePack = images
|
||||
EventLogger.log(event: "change_image_pack_id", withData: ["id": images.rawValue])
|
||||
AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue))
|
||||
}) {
|
||||
HStack {
|
||||
HStack(spacing: 16) {
|
||||
@@ -358,7 +358,7 @@ struct VotingLayoutPickerCompact: View {
|
||||
votingLayoutStyle = layout.rawValue
|
||||
}
|
||||
}
|
||||
EventLogger.log(event: "change_voting_layout", withData: ["layout": layout.displayName])
|
||||
AnalyticsManager.shared.track(.votingLayoutChanged(layout: layout.displayName))
|
||||
}) {
|
||||
VStack(spacing: 6) {
|
||||
layoutIcon(for: layout)
|
||||
@@ -490,7 +490,7 @@ struct CustomWidgetSection: View {
|
||||
.frame(width: 60, height: 60)
|
||||
.cornerRadius(12)
|
||||
.onTapGesture {
|
||||
EventLogger.log(event: "show_widget")
|
||||
AnalyticsManager.shared.track(.widgetViewed)
|
||||
selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel
|
||||
selectedWidget.showSheet = true
|
||||
}
|
||||
@@ -498,7 +498,7 @@ struct CustomWidgetSection: View {
|
||||
|
||||
// Add button
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_create_new_widget")
|
||||
AnalyticsManager.shared.track(.widgetCreateTapped)
|
||||
selectedWidget.selectedItem = CustomWidgetModel.randomWidget
|
||||
selectedWidget.showSheet = true
|
||||
}) {
|
||||
@@ -547,12 +547,11 @@ struct PersonalityPackPickerCompact: View {
|
||||
Button(action: {
|
||||
// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
// showOver18Alert = true
|
||||
// EventLogger.log(event: "show_over_18_alert")
|
||||
// } else {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .medium)
|
||||
impactMed.impactOccurred()
|
||||
personalityPack = aPack
|
||||
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
||||
AnalyticsManager.shared.track(.personalityPackChanged(packTitle: aPack.title()))
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
// }
|
||||
}) {
|
||||
@@ -651,7 +650,7 @@ struct SubscriptionBannerView: View {
|
||||
|
||||
private var notSubscribedView: some View {
|
||||
Button(action: {
|
||||
EventLogger.log(event: "customize_subscribe_tapped")
|
||||
AnalyticsManager.shared.track(.paywallSubscribeTapped(source: "customize"))
|
||||
showSubscriptionStore = true
|
||||
}) {
|
||||
HStack(spacing: 12) {
|
||||
@@ -722,7 +721,7 @@ struct DayViewStylePickerCompact: View {
|
||||
}
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .medium)
|
||||
impactMed.impactOccurred()
|
||||
EventLogger.log(event: "change_day_view_style", withData: ["style": style.displayName])
|
||||
AnalyticsManager.shared.track(.dayViewStyleChanged(style: style.displayName))
|
||||
}) {
|
||||
VStack(spacing: 6) {
|
||||
styleIcon(for: style)
|
||||
|
||||
@@ -24,7 +24,7 @@ struct CustomWigetView: View {
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(10)
|
||||
.onTapGesture {
|
||||
EventLogger.log(event: "show_widget")
|
||||
AnalyticsManager.shared.track(.widgetViewed)
|
||||
selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel
|
||||
selectedWidget.showSheet = true
|
||||
}
|
||||
@@ -35,7 +35,7 @@ struct CustomWigetView: View {
|
||||
Image(systemName: "plus")
|
||||
)
|
||||
.onTapGesture {
|
||||
EventLogger.log(event: "tap_create_new_widget")
|
||||
AnalyticsManager.shared.track(.widgetCreateTapped)
|
||||
selectedWidget.selectedItem = CustomWidgetModel.randomWidget
|
||||
selectedWidget.showSheet = true
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ struct IconPickerView: View {
|
||||
HStack {
|
||||
Button(action: {
|
||||
UIApplication.shared.setAlternateIconName(nil)
|
||||
EventLogger.log(event: "change_icon_title", withData: ["title": "default"])
|
||||
AnalyticsManager.shared.track(.appIconChanged(iconTitle: "default"))
|
||||
}, label: {
|
||||
Image("AppIconImage", bundle: .main)
|
||||
.resizable()
|
||||
@@ -73,7 +73,7 @@ struct IconPickerView: View {
|
||||
UIApplication.shared.setAlternateIconName(iconSet.1) { (error) in
|
||||
// FIXME: Handle error
|
||||
}
|
||||
EventLogger.log(event: "change_icon_title", withData: ["title": iconSet.1])
|
||||
AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1))
|
||||
}, label: {
|
||||
Image(iconSet.0, bundle: .main)
|
||||
.resizable()
|
||||
|
||||
@@ -45,7 +45,7 @@ struct ImagePackPickerView: View {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
imagePack = images
|
||||
EventLogger.log(event: "change_image_pack_id", withData: ["id": images.rawValue])
|
||||
AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue))
|
||||
}
|
||||
if images.rawValue != (MoodImages.allCases.sorted(by: { $0.rawValue > $1.rawValue }).first?.rawValue) ?? 0 {
|
||||
Divider()
|
||||
|
||||
@@ -41,14 +41,10 @@ struct PersonalityPackPickerView: View {
|
||||
.padding(5)
|
||||
)
|
||||
.onTapGesture {
|
||||
// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
|
||||
// showOver18Alert = true
|
||||
// EventLogger.log(event: "show_over_18_alert")
|
||||
// } else {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
personalityPack = aPack
|
||||
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
|
||||
AnalyticsManager.shared.track(.personalityPackChanged(packTitle: aPack.title()))
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ struct ShapePickerView: View {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
shape = ashape
|
||||
EventLogger.log(event: "change_mood_shape_id", withData: ["id": shape.rawValue])
|
||||
AnalyticsManager.shared.track(.moodShapeChanged(shapeId: shape.rawValue))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
|
||||
@@ -71,7 +71,7 @@ struct ThemePickerView: View {
|
||||
selectedTheme = theme
|
||||
}
|
||||
|
||||
EventLogger.log(event: "change_theme_id", withData: ["id": theme.rawValue])
|
||||
AnalyticsManager.shared.track(.themeChanged(themeId: theme.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ struct VotingLayoutPickerView: View {
|
||||
votingLayoutStyle = layout.rawValue
|
||||
}
|
||||
}
|
||||
EventLogger.log(event: "change_voting_layout", withData: ["layout": layout.displayName])
|
||||
AnalyticsManager.shared.track(.votingLayoutChanged(layout: layout.displayName))
|
||||
}) {
|
||||
VStack(spacing: 6) {
|
||||
layoutIcon(for: layout)
|
||||
|
||||
@@ -40,7 +40,7 @@ struct DayView: View {
|
||||
ZStack {
|
||||
mainView
|
||||
.onAppear(perform: {
|
||||
EventLogger.log(event: "show_home_view")
|
||||
AnalyticsManager.shared.trackScreen(.day)
|
||||
})
|
||||
.sheet(isPresented: $showingSheet) {
|
||||
SettingsView()
|
||||
|
||||
@@ -16,6 +16,7 @@ struct FeelsSubscriptionStoreView: View {
|
||||
@AppStorage(UserDefaultsStore.Keys.paywallStyle.rawValue, store: GroupUserDefaults.groupDefaults)
|
||||
private var paywallStyleRaw: Int = 0
|
||||
|
||||
var source: String = "unknown"
|
||||
var style: PaywallStyle?
|
||||
|
||||
private var currentStyle: PaywallStyle {
|
||||
@@ -33,9 +34,29 @@ struct FeelsSubscriptionStoreView: View {
|
||||
.storeButton(.visible, for: .restorePurchases)
|
||||
.subscriptionStoreButtonLabel(.multiline)
|
||||
.tint(tintColor)
|
||||
.onInAppPurchaseCompletion { _, result in
|
||||
if case .success(.success(_)) = result {
|
||||
.onAppear {
|
||||
AnalyticsManager.shared.trackPaywallViewed(source: source)
|
||||
}
|
||||
.onInAppPurchaseStart { product in
|
||||
AnalyticsManager.shared.trackPurchaseStarted(productId: product.id, source: source)
|
||||
}
|
||||
.onInAppPurchaseCompletion { product, result in
|
||||
switch result {
|
||||
case .success(.success(_)):
|
||||
AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: source)
|
||||
Task { @MainActor in
|
||||
await iapManager.checkSubscriptionStatus()
|
||||
iapManager.trackSubscriptionAnalytics(source: "purchase_success")
|
||||
}
|
||||
dismiss()
|
||||
case .success(.userCancelled):
|
||||
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "user_cancelled")
|
||||
case .success(.pending):
|
||||
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "pending")
|
||||
case .failure(let error):
|
||||
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: error.localizedDescription)
|
||||
@unknown default:
|
||||
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "unknown_result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ struct IAPWarningView: View {
|
||||
.padding()
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "iap_warning")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,14 +171,14 @@ struct InsightsView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "insights_gate")
|
||||
}
|
||||
.background(
|
||||
theme.currentTheme.bg
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
)
|
||||
.onAppear {
|
||||
EventLogger.log(event: "show_insights_view")
|
||||
AnalyticsManager.shared.trackScreen(.insights)
|
||||
viewModel.generateInsights()
|
||||
}
|
||||
.padding(.top)
|
||||
|
||||
@@ -77,7 +77,7 @@ struct MonthDetailView: View {
|
||||
)
|
||||
}
|
||||
.onAppear(perform: {
|
||||
EventLogger.log(event: "show_month_detail_view")
|
||||
AnalyticsManager.shared.trackScreen(.monthDetail)
|
||||
})
|
||||
.background(
|
||||
theme.currentTheme.bg
|
||||
|
||||
@@ -337,10 +337,10 @@ struct MonthView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "month_gate")
|
||||
}
|
||||
.onAppear(perform: {
|
||||
EventLogger.log(event: "show_month_view")
|
||||
AnalyticsManager.shared.trackScreen(.month)
|
||||
})
|
||||
.padding([.top])
|
||||
.background(
|
||||
|
||||
@@ -32,7 +32,7 @@ struct PurchaseButtonView: View {
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.cornerRadius(10)
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "purchase_button")
|
||||
}
|
||||
.manageSubscriptionsSheet(isPresented: $showManageSubscriptions)
|
||||
}
|
||||
@@ -104,7 +104,8 @@ struct PurchaseButtonView: View {
|
||||
|
||||
private var subscriptionStatusBadge: some View {
|
||||
Group {
|
||||
if case .subscribed(_, let willAutoRenew) = iapManager.state {
|
||||
switch iapManager.state {
|
||||
case .subscribed(_, let willAutoRenew):
|
||||
if willAutoRenew {
|
||||
Text(String(localized: "subscription_status_active"))
|
||||
.font(.caption)
|
||||
@@ -122,6 +123,16 @@ struct PurchaseButtonView: View {
|
||||
.background(Color.orange)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
case .billingRetry, .gracePeriod:
|
||||
Text("Payment Issue")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.orange)
|
||||
.cornerRadius(4)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +177,7 @@ struct PurchaseButtonView: View {
|
||||
// Restore purchases
|
||||
Button {
|
||||
Task {
|
||||
await iapManager.restore()
|
||||
await iapManager.restore(source: "purchase_button")
|
||||
}
|
||||
} label: {
|
||||
Text(String(localized: "purchase_view_restore"))
|
||||
|
||||
@@ -38,7 +38,7 @@ struct PaywallPreviewSettingsView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFullPreview) {
|
||||
FeelsSubscriptionStoreView(style: selectedStyle)
|
||||
FeelsSubscriptionStoreView(source: "paywall_preview", style: selectedStyle)
|
||||
.environmentObject(iapManager)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ struct SettingsTabView: View {
|
||||
WhyUpgradeView()
|
||||
}
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "settings_tab")
|
||||
.environmentObject(iapManager)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ struct SettingsContentView: View {
|
||||
legalSectionHeader
|
||||
eulaButton
|
||||
privacyButton
|
||||
analyticsToggle
|
||||
|
||||
addTestDataButton
|
||||
|
||||
@@ -108,7 +109,7 @@ struct SettingsContentView: View {
|
||||
ReminderTimePickerView()
|
||||
}
|
||||
.onAppear(perform: {
|
||||
EventLogger.log(event: "show_settings_view")
|
||||
AnalyticsManager.shared.trackScreen(.settings)
|
||||
FeelsTipsManager.shared.onSettingsViewed()
|
||||
})
|
||||
}
|
||||
@@ -119,7 +120,7 @@ struct SettingsContentView: View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_reminder_time")
|
||||
AnalyticsManager.shared.track(.reminderTimeTapped)
|
||||
showReminderTimePicker = true
|
||||
}, label: {
|
||||
HStack(spacing: 12) {
|
||||
@@ -832,7 +833,7 @@ struct SettingsContentView: View {
|
||||
if newValue {
|
||||
let success = await authManager.enableLock()
|
||||
if !success {
|
||||
EventLogger.log(event: "privacy_lock_enable_failed")
|
||||
AnalyticsManager.shared.track(.privacyLockEnableFailed)
|
||||
}
|
||||
} else {
|
||||
authManager.disableLock()
|
||||
@@ -927,16 +928,16 @@ struct SettingsContentView: View {
|
||||
// Sync all existing moods to HealthKit
|
||||
await HealthKitManager.shared.syncAllMoods()
|
||||
} else {
|
||||
EventLogger.log(event: "healthkit_state_of_mind_not_authorized")
|
||||
AnalyticsManager.shared.track(.healthKitNotAuthorized)
|
||||
}
|
||||
} catch {
|
||||
print("HealthKit authorization failed: \(error)")
|
||||
EventLogger.log(event: "healthkit_enable_failed")
|
||||
AnalyticsManager.shared.track(.healthKitEnableFailed)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
healthService.isEnabled = false
|
||||
EventLogger.log(event: "healthkit_disabled")
|
||||
AnalyticsManager.shared.track(.healthKitDisabled)
|
||||
}
|
||||
}
|
||||
))
|
||||
@@ -988,7 +989,7 @@ struct SettingsContentView: View {
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.healthKitSyncTip()
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,7 +999,7 @@ struct SettingsContentView: View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_export_data")
|
||||
AnalyticsManager.shared.track(.exportTapped)
|
||||
showExportView = true
|
||||
}, label: {
|
||||
HStack(spacing: 12) {
|
||||
@@ -1035,7 +1036,7 @@ struct SettingsContentView: View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_show_onboarding")
|
||||
AnalyticsManager.shared.track(.onboardingReshown)
|
||||
showOnboarding.toggle()
|
||||
}, label: {
|
||||
Text(String(localized: "settings_view_show_onboarding"))
|
||||
@@ -1055,7 +1056,7 @@ struct SettingsContentView: View {
|
||||
Toggle(String(localized: "settings_use_delete_enable"),
|
||||
isOn: $deleteEnabled)
|
||||
.onChange(of: deleteEnabled) { _, newValue in
|
||||
EventLogger.log(event: "toggle_can_delete", withData: ["value": newValue])
|
||||
AnalyticsManager.shared.track(.deleteToggleChanged(enabled: newValue))
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityHint(String(localized: "Allow deleting mood entries by swiping"))
|
||||
@@ -1070,7 +1071,7 @@ struct SettingsContentView: View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "show_eula")
|
||||
AnalyticsManager.shared.track(.eulaViewed)
|
||||
if let url = URL(string: "https://feels.app/eula.html") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
@@ -1089,7 +1090,7 @@ struct SettingsContentView: View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "show_privacy")
|
||||
AnalyticsManager.shared.track(.privacyPolicyViewed)
|
||||
if let url = URL(string: "https://feels.app/privacy.html") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
@@ -1104,6 +1105,48 @@ struct SettingsContentView: View {
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
// MARK: - Analytics Toggle
|
||||
|
||||
private var analyticsToggle: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Share Analytics")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text("Help improve Feels by sharing anonymous usage data")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { !AnalyticsManager.shared.isOptedOut },
|
||||
set: { enabled in
|
||||
if enabled {
|
||||
AnalyticsManager.shared.optIn()
|
||||
} else {
|
||||
AnalyticsManager.shared.optOut()
|
||||
}
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.accessibilityLabel("Share Analytics")
|
||||
.accessibilityHint("Toggle anonymous usage analytics")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
private func openInFilesApp(_ url: URL) {
|
||||
@@ -1175,7 +1218,7 @@ struct ReminderTimePickerView: View {
|
||||
// This handles notification scheduling and Live Activity rescheduling
|
||||
OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData)
|
||||
|
||||
EventLogger.log(event: "reminder_time_updated")
|
||||
AnalyticsManager.shared.track(.reminderTimeUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1231,6 +1274,7 @@ struct SettingsView: View {
|
||||
legalSectionHeader
|
||||
eulaButton
|
||||
privacyButton
|
||||
analyticsToggle
|
||||
// specialThanksCell
|
||||
}
|
||||
|
||||
@@ -1268,7 +1312,7 @@ struct SettingsView: View {
|
||||
))
|
||||
}
|
||||
.onAppear(perform: {
|
||||
EventLogger.log(event: "show_settings_view")
|
||||
AnalyticsManager.shared.trackScreen(.settings)
|
||||
})
|
||||
.background(
|
||||
theme.currentTheme.bg
|
||||
@@ -1282,7 +1326,7 @@ struct SettingsView: View {
|
||||
onCompletion: { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
EventLogger.log(event: "exported_file")
|
||||
AnalyticsManager.shared.track(.dataExported(format: "file", count: 0))
|
||||
print("Saved to \(url)")
|
||||
case .failure(let error):
|
||||
print(error.localizedDescription)
|
||||
@@ -1319,13 +1363,13 @@ struct SettingsView: View {
|
||||
DataController.shared.add(mood: mood, forDate: forDate, entryType: entryType)
|
||||
}
|
||||
DataController.shared.saveAndRunDataListeners()
|
||||
EventLogger.log(event: "import_file")
|
||||
AnalyticsManager.shared.track(.importSucceeded)
|
||||
} else {
|
||||
EventLogger.log(event: "error_import_file")
|
||||
AnalyticsManager.shared.track(.importFailed(error: nil))
|
||||
}
|
||||
} catch {
|
||||
// Handle failure.
|
||||
EventLogger.log(event: "error_import_file", withData: ["error": error.localizedDescription])
|
||||
AnalyticsManager.shared.track(.importFailed(error: error.localizedDescription))
|
||||
print("Unable to read file contents")
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
@@ -1402,7 +1446,7 @@ struct SettingsView: View {
|
||||
if newValue {
|
||||
let success = await authManager.enableLock()
|
||||
if !success {
|
||||
EventLogger.log(event: "privacy_lock_enable_failed")
|
||||
AnalyticsManager.shared.track(.privacyLockEnableFailed)
|
||||
}
|
||||
} else {
|
||||
authManager.disableLock()
|
||||
@@ -1465,16 +1509,16 @@ struct SettingsView: View {
|
||||
// Sync all existing moods to HealthKit
|
||||
await HealthKitManager.shared.syncAllMoods()
|
||||
} else {
|
||||
EventLogger.log(event: "healthkit_state_of_mind_not_authorized")
|
||||
AnalyticsManager.shared.track(.healthKitNotAuthorized)
|
||||
}
|
||||
} catch {
|
||||
print("HealthKit authorization failed: \(error)")
|
||||
EventLogger.log(event: "healthkit_enable_failed")
|
||||
AnalyticsManager.shared.track(.healthKitEnableFailed)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
healthService.isEnabled = false
|
||||
EventLogger.log(event: "healthkit_disabled")
|
||||
AnalyticsManager.shared.track(.healthKitDisabled)
|
||||
}
|
||||
}
|
||||
))
|
||||
@@ -1518,7 +1562,7 @@ struct SettingsView: View {
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1528,7 +1572,7 @@ struct SettingsView: View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_export_data")
|
||||
AnalyticsManager.shared.track(.exportTapped)
|
||||
showExportView = true
|
||||
}, label: {
|
||||
HStack(spacing: 12) {
|
||||
@@ -1558,12 +1602,12 @@ struct SettingsView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
|
||||
private var closeButtonView: some View {
|
||||
HStack{
|
||||
Spacer()
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_settings_close")
|
||||
AnalyticsManager.shared.track(.settingsClosed)
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text(String(localized: "settings_view_exit"))
|
||||
@@ -1578,7 +1622,7 @@ struct SettingsView: View {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
VStack {
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_show_special_thanks")
|
||||
AnalyticsManager.shared.track(.specialThanksViewed)
|
||||
withAnimation{
|
||||
showSpecialThanks.toggle()
|
||||
}
|
||||
@@ -1753,7 +1797,7 @@ struct SettingsView: View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "tap_show_onboarding")
|
||||
AnalyticsManager.shared.track(.onboardingReshown)
|
||||
showOnboarding.toggle()
|
||||
}, label: {
|
||||
Text(String(localized: "settings_view_show_onboarding"))
|
||||
@@ -1769,7 +1813,7 @@ struct SettingsView: View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "show_eula")
|
||||
AnalyticsManager.shared.track(.eulaViewed)
|
||||
openURL(URL(string: "https://feels.app/eula.html")!)
|
||||
}, label: {
|
||||
Text(String(localized: "settings_view_show_eula"))
|
||||
@@ -1785,7 +1829,7 @@ struct SettingsView: View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
EventLogger.log(event: "show_privacy")
|
||||
AnalyticsManager.shared.track(.privacyPolicyViewed)
|
||||
openURL(URL(string: "https://feels.app/privacy.html")!)
|
||||
}, label: {
|
||||
Text(String(localized: "settings_view_show_privacy"))
|
||||
@@ -1796,7 +1840,47 @@ struct SettingsView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
|
||||
private var analyticsToggle: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "chart.bar.xaxis")
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Share Analytics")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text("Help improve Feels by sharing anonymous usage data")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { !AnalyticsManager.shared.isOptedOut },
|
||||
set: { enabled in
|
||||
if enabled {
|
||||
AnalyticsManager.shared.optIn()
|
||||
} else {
|
||||
AnalyticsManager.shared.optOut()
|
||||
}
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.accessibilityLabel("Share Analytics")
|
||||
.accessibilityHint("Toggle anonymous usage analytics")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
}
|
||||
|
||||
private var canDelete: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
@@ -1804,7 +1888,7 @@ struct SettingsView: View {
|
||||
Toggle(String(localized: "settings_use_delete_enable"),
|
||||
isOn: $deleteEnabled)
|
||||
.onChange(of: deleteEnabled) { _, newValue in
|
||||
EventLogger.log(event: "toggle_can_delete", withData: ["value": newValue])
|
||||
AnalyticsManager.shared.track(.deleteToggleChanged(enabled: newValue))
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
.padding()
|
||||
@@ -1819,7 +1903,7 @@ struct SettingsView: View {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
showingExporter.toggle()
|
||||
EventLogger.log(event: "export_data", withData: ["title": "default"])
|
||||
AnalyticsManager.shared.track(.exportTapped)
|
||||
}, label: {
|
||||
Text("Export")
|
||||
.foregroundColor(textColor)
|
||||
@@ -1835,7 +1919,7 @@ struct SettingsView: View {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
showingImporter.toggle()
|
||||
EventLogger.log(event: "import_data", withData: ["title": "default"])
|
||||
AnalyticsManager.shared.track(.importTapped)
|
||||
}, label: {
|
||||
Text("Import")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
@@ -98,8 +98,7 @@ struct SwitchableView: View {
|
||||
.onTapGesture {
|
||||
viewType = viewType.next()
|
||||
self.headerTypeChanged(viewType)
|
||||
EventLogger.log(event: "switchable_view_header_changed",
|
||||
withData: ["view_type_id": viewType.rawValue, "days_back": daysBack])
|
||||
AnalyticsManager.shared.track(.viewHeaderChanged(header: String(describing: viewType)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ struct YearView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
FeelsSubscriptionStoreView(source: "year_gate")
|
||||
}
|
||||
.sheet(item: $sharePickerData) { data in
|
||||
SharingStylePickerView(title: data.title, designs: data.designs)
|
||||
|
||||
Reference in New Issue
Block a user