From c334ce0bd0215fd584a087121042101b48af7d9d Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 7 Dec 2025 23:53:00 -0600 Subject: [PATCH] Add PostHog analytics integration for Android and iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive analytics tracking across both platforms: Android (Kotlin): - Add PostHog SDK dependency and initialization in MainActivity - Create expect/actual pattern for cross-platform analytics (commonMain/androidMain/iosMain/jvmMain/jsMain/wasmJsMain) - Track screen views: registration, login, residences, tasks, contractors, documents, notifications, profile - Track key events: user_registered, user_signed_in, residence_created, task_created, contractor_created, document_created - Track paywall events: contractor_paywall_shown, documents_paywall_shown - Track sharing events: residence_shared, contractor_shared - Track theme_changed event iOS (Swift): - Add PostHog iOS SDK via SPM - Create PostHogAnalytics wrapper and AnalyticsEvents constants - Initialize SDK in iOSApp with session replay support - Track same screen views and events as Android - Track user identification after login/registration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- composeApp/build.gradle.kts | 3 + .../kotlin/com/example/casera/MainActivity.kt | 4 + .../analytics/PostHogAnalytics.android.kt | 74 +++++++++++ .../platform/ContractorSharing.android.kt | 4 + .../platform/ResidenceSharing.android.kt | 7 ++ .../com/example/casera/analytics/Analytics.kt | 61 +++++++++ .../ui/components/AddContractorDialog.kt | 8 +- .../casera/ui/components/AddTaskDialog.kt | 7 ++ .../casera/ui/screens/ContractorsScreen.kt | 7 ++ .../casera/ui/screens/DocumentFormScreen.kt | 21 ++++ .../casera/ui/screens/DocumentsScreen.kt | 8 ++ .../example/casera/ui/screens/LoginScreen.kt | 5 + .../screens/NotificationPreferencesScreen.kt | 5 +- .../casera/ui/screens/ProfileScreen.kt | 10 +- .../casera/ui/screens/RegisterScreen.kt | 11 ++ .../ui/screens/ResidenceDetailScreen.kt | 7 ++ .../casera/ui/screens/ResidenceFormScreen.kt | 13 ++ .../casera/ui/screens/ResidencesScreen.kt | 4 + .../example/casera/ui/screens/TasksScreen.kt | 3 + .../casera/analytics/PostHogAnalytics.ios.kt | 32 +++++ .../casera/analytics/PostHogAnalytics.js.kt | 31 +++++ .../casera/analytics/PostHogAnalytics.jvm.kt | 31 +++++ .../analytics/PostHogAnalytics.wasmJs.kt | 31 +++++ iosApp/iosApp.xcodeproj/project.pbxproj | 25 ++++ .../xcshareddata/swiftpm/Package.resolved | 15 +++ .../iosApp/Analytics/PostHogAnalytics.swift | 116 ++++++++++++++++++ .../Contractor/ContractorFormSheet.swift | 6 + .../Contractor/ContractorSharingManager.swift | 2 + .../Contractor/ContractorsListView.swift | 6 +- .../iosApp/Documents/DocumentFormView.swift | 8 ++ .../Documents/DocumentsWarrantiesView.swift | 4 + .../iosApp/Login/AppleSignInViewModel.swift | 9 ++ iosApp/iosApp/Login/LoginViewModel.swift | 10 ++ .../Profile/NotificationPreferencesView.swift | 2 + iosApp/iosApp/Profile/ProfileTabView.swift | 3 + .../iosApp/Profile/ThemeSelectionView.swift | 3 + iosApp/iosApp/Register/RegisterView.swift | 3 + .../iosApp/Register/RegisterViewModel.swift | 9 +- .../Residence/ResidenceSharingManager.swift | 2 + .../iosApp/Residence/ResidencesListView.swift | 1 + iosApp/iosApp/ResidenceFormView.swift | 7 ++ iosApp/iosApp/Task/AllTasksView.swift | 1 + iosApp/iosApp/Task/TaskFormView.swift | 6 + iosApp/iosApp/iOSApp.swift | 3 + 44 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/example/casera/analytics/PostHogAnalytics.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/analytics/Analytics.kt create mode 100644 composeApp/src/iosMain/kotlin/com/example/casera/analytics/PostHogAnalytics.ios.kt create mode 100644 composeApp/src/jsMain/kotlin/com/example/casera/analytics/PostHogAnalytics.js.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/example/casera/analytics/PostHogAnalytics.jvm.kt create mode 100644 composeApp/src/wasmJsMain/kotlin/com/example/casera/analytics/PostHogAnalytics.wasmJs.kt create mode 100644 iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 iosApp/iosApp/Analytics/PostHogAnalytics.swift diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 74ae0fc..f8e6ee8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -53,6 +53,9 @@ kotlin { // implementation(libs.ktor.client.logging) implementation(libs.kotlinx.coroutines.android) implementation(libs.google.billing) + + // PostHog Analytics + implementation("com.posthog:posthog-android:3.8.2") } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt index c5bb46a..13d08fd 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt @@ -37,6 +37,7 @@ import com.example.casera.data.DataManager import com.example.casera.data.PersistenceManager import com.example.casera.models.CaseraPackageType import com.example.casera.models.detectCaseraPackageType +import com.example.casera.analytics.PostHogAnalytics import kotlinx.coroutines.launch class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { @@ -71,6 +72,9 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { // Initialize BillingManager for subscription management billingManager = BillingManager.getInstance(applicationContext) + // Initialize PostHog Analytics + PostHogAnalytics.initialize(application, debug = true) // Set debug=false for release + // Handle deep link, notification navigation, and file import from intent handleDeepLink(intent) handleNotificationNavigation(intent) diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/analytics/PostHogAnalytics.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/analytics/PostHogAnalytics.android.kt new file mode 100644 index 0000000..600b65e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/analytics/PostHogAnalytics.android.kt @@ -0,0 +1,74 @@ +package com.example.casera.analytics + +import android.app.Application +import com.posthog.PostHog +import com.posthog.android.PostHogAndroid +import com.posthog.android.PostHogAndroidConfig + +/** + * Android implementation of PostHog Analytics. + */ +actual object PostHogAnalytics { + // TODO: Replace with your actual PostHog API key + private const val API_KEY = "YOUR_POSTHOG_API_KEY" + private const val HOST = "https://us.i.posthog.com" + + private var isInitialized = false + private var application: Application? = null + + /** + * Initialize PostHog SDK with Application context. + * Call this in MainActivity.onCreate() before using other methods. + */ + fun initialize(application: Application, debug: Boolean = false) { + if (isInitialized) return + this.application = application + + val config = PostHogAndroidConfig(API_KEY, HOST).apply { + captureScreenViews = false // We'll track screens manually + captureApplicationLifecycleEvents = true + captureDeepLinks = true + this.debug = debug + + // Session Replay + sessionReplay = true + sessionReplayConfig.maskAllTextInputs = true + sessionReplayConfig.maskAllImages = false + } + + PostHogAndroid.setup(application, config) + isInitialized = true + } + + /** + * Initialize from common code (no-op on Android, use initialize(Application) instead) + */ + actual fun initialize() { + // No-op - Android requires Application context, use initialize(Application) instead + } + + actual fun identify(userId: String, properties: Map?) { + if (!isInitialized) return + PostHog.identify(userId, userProperties = properties) + } + + actual fun capture(event: String, properties: Map?) { + if (!isInitialized) return + PostHog.capture(event, properties = properties) + } + + actual fun screen(screenName: String, properties: Map?) { + if (!isInitialized) return + PostHog.screen(screenName, properties = properties) + } + + actual fun reset() { + if (!isInitialized) return + PostHog.reset() + } + + actual fun flush() { + if (!isInitialized) return + PostHog.flush() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorSharing.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorSharing.android.kt index 0ac2f94..d5c4881 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorSharing.android.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ContractorSharing.android.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import com.example.casera.models.Contractor import com.example.casera.sharing.ContractorSharingManager +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents @Composable actual fun rememberShareContractor(): (Contractor) -> Unit { @@ -13,6 +15,8 @@ actual fun rememberShareContractor(): (Contractor) -> Unit { return { contractor: Contractor -> val intent = ContractorSharingManager.createShareIntent(context, contractor) if (intent != null) { + // Track contractor shared event + PostHogAnalytics.capture(AnalyticsEvents.CONTRACTOR_SHARED) context.startActivity(Intent.createChooser(intent, "Share Contractor")) } } diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceSharing.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceSharing.android.kt index d6f4ce3..4820ce9 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceSharing.android.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/ResidenceSharing.android.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.example.casera.models.Residence import com.example.casera.sharing.ResidenceSharingManager +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import kotlinx.coroutines.launch @Composable @@ -25,6 +27,11 @@ actual fun rememberShareResidence(): Pair val intent = ResidenceSharingManager.createShareIntent(context, residence) if (intent != null) { state = ResidenceSharingState(isLoading = false) + // Track residence shared event + PostHogAnalytics.capture( + AnalyticsEvents.RESIDENCE_SHARED, + mapOf("method" to "file") + ) context.startActivity(Intent.createChooser(intent, "Share Residence")) } else { state = ResidenceSharingState( diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/analytics/Analytics.kt b/composeApp/src/commonMain/kotlin/com/example/casera/analytics/Analytics.kt new file mode 100644 index 0000000..239aaa7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/analytics/Analytics.kt @@ -0,0 +1,61 @@ +package com.example.casera.analytics + +/** + * Common analytics interface for cross-platform event tracking. + * Platform-specific implementations use PostHog SDKs. + */ +expect object PostHogAnalytics { + fun initialize() + fun identify(userId: String, properties: Map? = null) + fun capture(event: String, properties: Map? = null) + fun screen(screenName: String, properties: Map? = null) + fun reset() + fun flush() +} + +/** + * Analytics event names - use these constants for consistency across the app + */ +object AnalyticsEvents { + // Authentication + const val REGISTRATION_SCREEN_SHOWN = "registration_screen_shown" + const val USER_REGISTERED = "user_registered" + const val USER_SIGNED_IN = "user_signed_in" + const val USER_SIGNED_IN_APPLE = "user_signed_in_apple" + + // Residence + const val RESIDENCE_SCREEN_SHOWN = "residence_screen_shown" + const val NEW_RESIDENCE_SCREEN_SHOWN = "new_residence_screen_shown" + const val RESIDENCE_CREATED = "residence_created" + const val RESIDENCE_LIMIT_REACHED = "residence_limit_reached" + + // Task + const val TASK_SCREEN_SHOWN = "task_screen_shown" + const val NEW_TASK_SCREEN_SHOWN = "new_task_screen_shown" + const val TASK_CREATED = "task_created" + + // Contractor + const val CONTRACTOR_SCREEN_SHOWN = "contractor_screen_shown" + const val NEW_CONTRACTOR_SCREEN_SHOWN = "new_contractor_screen_shown" + const val CONTRACTOR_CREATED = "contractor_created" + const val CONTRACTOR_PAYWALL_SHOWN = "contractor_paywall_shown" + + // Documents + const val DOCUMENTS_SCREEN_SHOWN = "documents_screen_shown" + const val NEW_DOCUMENT_SCREEN_SHOWN = "new_document_screen_shown" + const val DOCUMENT_CREATED = "document_created" + const val DOCUMENTS_PAYWALL_SHOWN = "documents_paywall_shown" + + // Sharing + const val SHARE_RESIDENCE_SCREEN_SHOWN = "share_residence_screen_shown" + const val RESIDENCE_SHARED = "residence_shared" + const val SHARE_RESIDENCE_PAYWALL_SHOWN = "share_residence_paywall_shown" + const val SHARE_CONTRACTOR_SCREEN_SHOWN = "share_contractor_screen_shown" + const val CONTRACTOR_SHARED = "contractor_shared" + const val SHARE_CONTRACTOR_PAYWALL_SHOWN = "share_contractor_paywall_shown" + + // Settings + const val NOTIFICATION_SETTINGS_SCREEN_SHOWN = "notification_settings_screen_shown" + const val SETTINGS_SCREEN_SHOWN = "settings_screen_shown" + const val THEME_CHANGED = "theme_changed" +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt index 3e675ab..6aa6659 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddContractorDialog.kt @@ -20,6 +20,8 @@ import com.example.casera.models.ContractorUpdateRequest import com.example.casera.models.Residence import com.example.casera.network.ApiResult import com.example.casera.repository.LookupsRepository +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -53,8 +55,11 @@ fun AddContractorDialog( var expandedResidenceMenu by remember { mutableStateOf(false) } val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState() - // Load residences for picker + // Track screen view and load residences for picker LaunchedEffect(Unit) { + if (contractorId == null) { + PostHogAnalytics.screen(AnalyticsEvents.NEW_CONTRACTOR_SCREEN_SHOWN) + } residenceViewModel.loadResidences() } @@ -91,6 +96,7 @@ fun AddContractorDialog( LaunchedEffect(createState) { if (createState is ApiResult.Success) { + PostHogAnalytics.capture(AnalyticsEvents.CONTRACTOR_CREATED) onContractorSaved() viewModel.resetCreateState() } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt index 2aea5f6..b2a293c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt @@ -22,6 +22,8 @@ import com.example.casera.models.TaskCategory import com.example.casera.models.TaskCreateRequest import com.example.casera.models.TaskFrequency import com.example.casera.models.TaskPriority +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -110,6 +112,11 @@ fun AddTaskDialog( showSuggestions = false } + // Track screen view + LaunchedEffect(Unit) { + PostHogAnalytics.screen(AnalyticsEvents.NEW_TASK_SCREEN_SHOWN) + } + // Set defaults when data loads LaunchedEffect(frequencies) { if (frequencies.isNotEmpty()) { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorsScreen.kt index ca7e152..8bcffac 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorsScreen.kt @@ -29,6 +29,8 @@ import com.example.casera.network.ApiResult import com.example.casera.repository.LookupsRepository import com.example.casera.ui.subscription.UpgradeFeatureScreen import com.example.casera.utils.SubscriptionHelper +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -58,6 +60,7 @@ fun ContractorsScreen( var isRefreshing by remember { mutableStateOf(false) } LaunchedEffect(Unit) { + PostHogAnalytics.screen(AnalyticsEvents.CONTRACTOR_SCREEN_SHOWN) viewModel.loadContractors() } @@ -182,6 +185,10 @@ fun ContractorsScreen( if (canAdd.allowed) { showAddDialog = true } else { + PostHogAnalytics.capture( + AnalyticsEvents.CONTRACTOR_PAYWALL_SHOWN, + mapOf("current_count" to currentCount) + ) showUpgradeDialog = true } }, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt index 38cd987..f572076 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentFormScreen.kt @@ -26,6 +26,8 @@ import com.example.casera.network.ApiResult import com.example.casera.platform.ImageData import com.example.casera.platform.rememberImagePicker import com.example.casera.platform.rememberCameraPicker +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -103,6 +105,17 @@ fun DocumentFormScreen( } } + // Track screen view + LaunchedEffect(Unit) { + if (!isEditMode) { + val type = if (initialDocumentType == "warranty") "warranty" else "document" + PostHogAnalytics.screen( + AnalyticsEvents.NEW_DOCUMENT_SCREEN_SHOWN, + mapOf("type" to type) + ) + } + } + // Load residences if needed LaunchedEffect(needsResidenceSelection) { if (needsResidenceSelection) { @@ -148,6 +161,14 @@ fun DocumentFormScreen( // Handle success LaunchedEffect(operationState) { if (operationState is ApiResult.Success) { + if (!isEditMode) { + // Track document creation + val type = if (selectedDocumentType == "warranty") "warranty" else "document" + PostHogAnalytics.capture( + AnalyticsEvents.DOCUMENT_CREATED, + mapOf("type" to type) + ) + } if (isEditMode) { documentViewModel.resetUpdateState() } else { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentsScreen.kt index dea8945..6fc148c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentsScreen.kt @@ -16,6 +16,8 @@ import com.example.casera.ui.subscription.UpgradeFeatureScreen import com.example.casera.utils.SubscriptionHelper import com.example.casera.viewmodel.DocumentViewModel import com.example.casera.models.* +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -47,6 +49,8 @@ fun DocumentsScreen( var showUpgradeDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { + // Track screen view + PostHogAnalytics.screen(AnalyticsEvents.DOCUMENTS_SCREEN_SHOWN) // Load warranties by default (documentType="warranty") documentViewModel.loadDocuments( residenceId = residenceId, @@ -180,6 +184,10 @@ fun DocumentsScreen( // Pass residenceId even if null - AddDocumentScreen will handle it onNavigateToAddDocument(residenceId ?: -1, documentType) } else { + PostHogAnalytics.capture( + AnalyticsEvents.DOCUMENTS_PAYWALL_SHOWN, + mapOf("current_count" to currentCount) + ) showUpgradeDialog = true } }, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/LoginScreen.kt index 991756b..e686d6f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/LoginScreen.kt @@ -26,6 +26,8 @@ import com.example.casera.ui.components.auth.AuthHeader import com.example.casera.ui.components.common.ErrorCard import com.example.casera.viewmodel.AuthViewModel import com.example.casera.network.ApiResult +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -52,6 +54,9 @@ fun LoginScreen( when (loginState) { is ApiResult.Success -> { val user = (loginState as ApiResult.Success).data.user + // Track successful login + PostHogAnalytics.capture(AnalyticsEvents.USER_SIGNED_IN, mapOf("method" to "email")) + PostHogAnalytics.identify(user.id.toString(), mapOf("email" to (user.email ?: ""), "username" to (user.username ?: ""))) onLoginSuccess(user) } else -> {} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt index 484a19a..db1585d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt @@ -19,6 +19,8 @@ import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.AppSpacing import com.example.casera.util.DateUtils import com.example.casera.viewmodel.NotificationPreferencesViewModel +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -56,8 +58,9 @@ fun NotificationPreferencesScreen( val defaultTaskOverdueLocalHour = 9 // 9 AM local val defaultDailyDigestLocalHour = 8 // 8 AM local - // Load preferences on first render + // Track screen view and load preferences on first render LaunchedEffect(Unit) { + PostHogAnalytics.screen(AnalyticsEvents.NOTIFICATION_SETTINGS_SCREEN_SHOWN) viewModel.loadPreferences() } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt index 61d07c9..19c4915 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt @@ -29,6 +29,8 @@ import com.example.casera.storage.TokenStorage import com.example.casera.cache.SubscriptionCache import com.example.casera.ui.subscription.UpgradePromptDialog import androidx.compose.runtime.getValue +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -66,8 +68,9 @@ fun ProfileScreen( errorTitle = stringResource(Res.string.profile_update_failed) ) - // Load current user data + // Track screen view and load current user data LaunchedEffect(Unit) { + PostHogAnalytics.screen(AnalyticsEvents.SETTINGS_SCREEN_SHOWN) val token = TokenStorage.getToken() if (token != null) { val authApi = com.example.casera.network.AuthApi() @@ -528,6 +531,11 @@ fun ProfileScreen( currentTheme = currentTheme, onThemeSelected = { theme -> ThemeManager.setTheme(theme) + // Track theme change + PostHogAnalytics.capture( + AnalyticsEvents.THEME_CHANGED, + mapOf("theme" to theme.id) + ) showThemePicker = false }, onDismiss = { showThemePicker = false } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/RegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/RegisterScreen.kt index 2bb0e1a..9943d66 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/RegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/RegisterScreen.kt @@ -19,6 +19,8 @@ import com.example.casera.ui.components.auth.AuthHeader import com.example.casera.ui.components.common.ErrorCard import com.example.casera.viewmodel.AuthViewModel import com.example.casera.network.ApiResult +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -44,9 +46,18 @@ fun RegisterScreen( errorTitle = stringResource(Res.string.error_generic) ) + // Track screen view + LaunchedEffect(Unit) { + PostHogAnalytics.screen(AnalyticsEvents.REGISTRATION_SCREEN_SHOWN) + } + LaunchedEffect(createState) { when (createState) { is ApiResult.Success -> { + // Track successful registration + val user = (createState as ApiResult.Success).data.user + PostHogAnalytics.capture(AnalyticsEvents.USER_REGISTERED, mapOf("method" to "email")) + PostHogAnalytics.identify(user.id.toString(), mapOf("email" to (user.email ?: ""), "username" to (user.username ?: ""))) viewModel.resetRegisterState() onRegisterSuccess() } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt index b7ed08e..35e4260 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt @@ -37,6 +37,8 @@ import com.example.casera.cache.SubscriptionCache import com.example.casera.data.DataManager import com.example.casera.util.DateUtils import com.example.casera.platform.rememberShareResidence +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -126,6 +128,11 @@ fun ResidenceDetailScreen( LaunchedEffect(taskAddNewTaskState) { when (taskAddNewTaskState) { is ApiResult.Success -> { + // Track task creation + PostHogAnalytics.capture( + AnalyticsEvents.TASK_CREATED, + mapOf("residence_id" to residenceId) + ) showNewTaskDialog = false taskViewModel.resetAddTaskState() residenceViewModel.loadResidenceTasks(residenceId) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt index 169a8c2..535b812 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt @@ -24,6 +24,8 @@ import com.example.casera.models.ResidenceUser import com.example.casera.network.ApiResult import com.example.casera.network.ResidenceApi import com.example.casera.storage.TokenStorage +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -102,6 +104,13 @@ fun ResidenceFormScreen( // Validation errors var nameError by remember { mutableStateOf("") } + // Track screen view + LaunchedEffect(Unit) { + if (!isEditMode) { + PostHogAnalytics.screen(AnalyticsEvents.NEW_RESIDENCE_SCREEN_SHOWN) + } + } + // Handle operation state changes LaunchedEffect(operationState) { when (operationState) { @@ -109,6 +118,10 @@ fun ResidenceFormScreen( if (isEditMode) { viewModel.resetUpdateState() } else { + // Track residence created + PostHogAnalytics.capture(AnalyticsEvents.RESIDENCE_CREATED, mapOf( + "residence_type" to (propertyType?.name ?: "unknown") + )) viewModel.resetCreateState() } onSuccess() diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt index c5628f4..d5b982a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt @@ -29,6 +29,8 @@ import com.example.casera.network.ApiResult import com.example.casera.utils.SubscriptionHelper import com.example.casera.ui.subscription.UpgradePromptDialog import com.example.casera.cache.SubscriptionCache +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -59,7 +61,9 @@ fun ResidencesScreen( return Pair(check.allowed, check.triggerKey) } + // Track screen view LaunchedEffect(Unit) { + PostHogAnalytics.screen(AnalyticsEvents.RESIDENCE_SCREEN_SHOWN) viewModel.loadMyResidences() } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/TasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/TasksScreen.kt index b6d1141..31ea83c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/TasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/TasksScreen.kt @@ -19,6 +19,8 @@ import com.example.casera.ui.utils.hexToColor import com.example.casera.viewmodel.TaskCompletionViewModel import com.example.casera.viewmodel.TaskViewModel import com.example.casera.network.ApiResult +import com.example.casera.analytics.PostHogAnalytics +import com.example.casera.analytics.AnalyticsEvents import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -47,6 +49,7 @@ fun TasksScreen( } LaunchedEffect(Unit) { + PostHogAnalytics.screen(AnalyticsEvents.TASK_SCREEN_SHOWN) viewModel.loadTasks() } diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/analytics/PostHogAnalytics.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/analytics/PostHogAnalytics.ios.kt new file mode 100644 index 0000000..56a4412 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/analytics/PostHogAnalytics.ios.kt @@ -0,0 +1,32 @@ +package com.example.casera.analytics + +/** + * iOS implementation of PostHog Analytics. + * Note: The actual PostHog SDK calls are made from Swift code. + * This is a stub that does nothing - iOS uses the native Swift wrapper directly. + */ +actual object PostHogAnalytics { + actual fun initialize() { + // iOS initialization is done in Swift via PostHogAnalytics.swift + } + + actual fun identify(userId: String, properties: Map?) { + // iOS uses Swift PostHogAnalytics.shared.identify() directly + } + + actual fun capture(event: String, properties: Map?) { + // iOS uses Swift PostHogAnalytics.shared.capture() directly + } + + actual fun screen(screenName: String, properties: Map?) { + // iOS uses Swift PostHogAnalytics.shared.screen() directly + } + + actual fun reset() { + // iOS uses Swift PostHogAnalytics.shared.reset() directly + } + + actual fun flush() { + // iOS uses Swift PostHogAnalytics.shared.flush() directly + } +} diff --git a/composeApp/src/jsMain/kotlin/com/example/casera/analytics/PostHogAnalytics.js.kt b/composeApp/src/jsMain/kotlin/com/example/casera/analytics/PostHogAnalytics.js.kt new file mode 100644 index 0000000..5ba1698 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/casera/analytics/PostHogAnalytics.js.kt @@ -0,0 +1,31 @@ +package com.example.casera.analytics + +/** + * JS/Web implementation of PostHog Analytics. + * Currently a no-op stub - could be implemented with PostHog JS SDK. + */ +actual object PostHogAnalytics { + actual fun initialize() { + // No-op for web + } + + actual fun identify(userId: String, properties: Map?) { + // No-op for web + } + + actual fun capture(event: String, properties: Map?) { + // No-op for web + } + + actual fun screen(screenName: String, properties: Map?) { + // No-op for web + } + + actual fun reset() { + // No-op for web + } + + actual fun flush() { + // No-op for web + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/analytics/PostHogAnalytics.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/analytics/PostHogAnalytics.jvm.kt new file mode 100644 index 0000000..36b3144 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/analytics/PostHogAnalytics.jvm.kt @@ -0,0 +1,31 @@ +package com.example.casera.analytics + +/** + * JVM/Desktop implementation of PostHog Analytics. + * Currently a no-op stub - PostHog doesn't have a native JVM SDK. + */ +actual object PostHogAnalytics { + actual fun initialize() { + // No-op for desktop + } + + actual fun identify(userId: String, properties: Map?) { + // No-op for desktop + } + + actual fun capture(event: String, properties: Map?) { + // No-op for desktop + } + + actual fun screen(screenName: String, properties: Map?) { + // No-op for desktop + } + + actual fun reset() { + // No-op for desktop + } + + actual fun flush() { + // No-op for desktop + } +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/analytics/PostHogAnalytics.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/analytics/PostHogAnalytics.wasmJs.kt new file mode 100644 index 0000000..c82de82 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/analytics/PostHogAnalytics.wasmJs.kt @@ -0,0 +1,31 @@ +package com.example.casera.analytics + +/** + * WasmJS/Web implementation of PostHog Analytics. + * Currently a no-op stub - could be implemented with PostHog JS SDK. + */ +actual object PostHogAnalytics { + actual fun initialize() { + // No-op for web + } + + actual fun identify(userId: String, properties: Map?) { + // No-op for web + } + + actual fun capture(event: String, properties: Map?) { + // No-op for web + } + + actual fun screen(screenName: String, properties: Map?) { + // No-op for web + } + + actual fun reset() { + // No-op for web + } + + actual fun flush() { + // No-op for web + } +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 3123b2c..2dea3ce 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 1C81F2772EE416EF000739EA /* CaseraQLPreview.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1C81F2692EE416EE000739EA /* CaseraQLPreview.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1C81F2822EE41BB6000739EA /* QuickLookThumbnailing.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1C81F2812EE41BB6000739EA /* QuickLookThumbnailing.framework */; }; 1C81F2892EE41BB6000739EA /* CaseraQLThumbnail.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1C81F2802EE41BB6000739EA /* CaseraQLThumbnail.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 1C81F3902EE69AF1000739EA /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 1C81F38F2EE69AF1000739EA /* PostHog */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -256,6 +257,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1C81F3902EE69AF1000739EA /* PostHog in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -450,6 +452,7 @@ ); name = Casera; packageProductDependencies = ( + 1C81F38F2EE69AF1000739EA /* PostHog */, ); productName = iosApp; productReference = 96A3DDC05E14B3F83E56282F /* Casera.app */; @@ -496,6 +499,9 @@ ); mainGroup = 86BC7E88090398B44B7DB0E4; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 1C81F38E2EE69698000739EA /* XCRemoteSwiftPackageReference "posthog-ios" */, + ); preferredProjectObjectVersion = 77; productRefGroup = FA6022B7B844191C54E57EB4 /* Products */; projectDirPath = ""; @@ -1198,6 +1204,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 1C81F38E2EE69698000739EA /* XCRemoteSwiftPackageReference "posthog-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/PostHog/posthog-ios.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.35.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1C81F38F2EE69AF1000739EA /* PostHog */ = { + isa = XCSwiftPackageProductDependency; + package = 1C81F38E2EE69698000739EA /* XCRemoteSwiftPackageReference "posthog-ios" */; + productName = PostHog; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 6A3E1D84F9F1A2FD92A75A6C /* Project object */; } diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..aca80d8 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "47cbe4ef2adc7155b834c1fb5ae451e260f9ef6ba19f0658c4fcafd3565fad48", + "pins" : [ + { + "identity" : "posthog-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PostHog/posthog-ios.git", + "state" : { + "revision" : "fac9fc77380d2a38c3389f3cf4505a534921ee41", + "version" : "3.35.1" + } + } + ], + "version" : 3 +} diff --git a/iosApp/iosApp/Analytics/PostHogAnalytics.swift b/iosApp/iosApp/Analytics/PostHogAnalytics.swift new file mode 100644 index 0000000..a76a1bd --- /dev/null +++ b/iosApp/iosApp/Analytics/PostHogAnalytics.swift @@ -0,0 +1,116 @@ +import Foundation +import PostHog + +/// PostHog Analytics wrapper for iOS. +/// Provides a simple interface for tracking events, screens, and user identification. +final class PostHogAnalytics { + static let shared = PostHogAnalytics() + + // TODO: Replace with your actual PostHog API key + private let apiKey = "phc_zAf8ZEwHtr4zB6UgheP1epStBTNKP8mDBMtwzQ0BzfU" + private let host = "https://us.i.posthog.com" + + private var isInitialized = false + + private init() {} + + /// Initialize PostHog SDK. Call this in App.init() + func initialize() { + guard !isInitialized else { return } + + let config = PostHogConfig(apiKey: apiKey, host: host) + config.captureScreenViews = false // We'll track screens manually for SwiftUI + config.captureApplicationLifecycleEvents = true + + #if DEBUG + config.debug = true + #endif + + // Session Replay (required for SwiftUI: use screenshot mode) + config.sessionReplay = true + config.sessionReplayConfig.screenshotMode = true // Required for SwiftUI + config.sessionReplayConfig.maskAllTextInputs = true // Privacy: mask text inputs + config.sessionReplayConfig.maskAllImages = false + + PostHogSDK.shared.setup(config) + isInitialized = true + } + + /// Identify a user. Call this after successful login/registration. + /// This links all future events to this user ID. + func identify(_ userId: String, properties: [String: Any]? = nil) { + guard isInitialized else { return } + PostHogSDK.shared.identify(userId, userProperties: properties) + } + + /// Capture a custom event with optional properties. + /// Use format: "object_action" (e.g., "residence_created", "task_completed") + func capture(_ event: String, properties: [String: Any]? = nil) { + guard isInitialized else { return } + PostHogSDK.shared.capture(event, properties: properties) + } + + /// Track a screen view. Call this when a screen appears. + func screen(_ screenName: String, properties: [String: Any]? = nil) { + guard isInitialized else { return } + PostHogSDK.shared.screen(screenName, properties: properties) + } + + /// Reset the user identity. Call this on logout. + /// This starts a new anonymous session. + func reset() { + guard isInitialized else { return } + PostHogSDK.shared.reset() + } + + /// Flush any queued events immediately. + func flush() { + guard isInitialized else { return } + PostHogSDK.shared.flush() + } +} + +/// Analytics event names - use these constants for consistency across the app +enum AnalyticsEvents { + // Authentication + static let registrationScreenShown = "registration_screen_shown" + static let userRegistered = "user_registered" + static let userSignedIn = "user_signed_in" + static let userSignedInApple = "user_signed_in_apple" + + // Residence + static let residenceScreenShown = "residence_screen_shown" + static let newResidenceScreenShown = "new_residence_screen_shown" + static let residenceCreated = "residence_created" + static let residenceLimitReached = "residence_limit_reached" + + // Task + static let taskScreenShown = "task_screen_shown" + static let newTaskScreenShown = "new_task_screen_shown" + static let taskCreated = "task_created" + + // Contractor + static let contractorScreenShown = "contractor_screen_shown" + static let newContractorScreenShown = "new_contractor_screen_shown" + static let contractorCreated = "contractor_created" + static let contractorPaywallShown = "contractor_paywall_shown" + + // Documents + static let documentsScreenShown = "documents_screen_shown" + static let newDocumentScreenShown = "new_document_screen_shown" + static let documentCreated = "document_created" + static let documentsPaywallShown = "documents_paywall_shown" + + // Sharing + static let shareResidenceScreenShown = "share_residence_screen_shown" + static let residenceShared = "residence_shared" + static let shareResidencePaywallShown = "share_residence_paywall_shown" + static let shareContractorScreenShown = "share_contractor_screen_shown" + static let contractorShared = "contractor_shared" + static let shareContractorPaywallShown = "share_contractor_paywall_shown" + + // Settings + static let notificationSettingsScreenShown = "notification_settings_screen_shown" + static let settingsScreenShown = "settings_screen_shown" + static let themeChanged = "theme_changed" +} diff --git a/iosApp/iosApp/Contractor/ContractorFormSheet.swift b/iosApp/iosApp/Contractor/ContractorFormSheet.swift index 019379a..1b55d92 100644 --- a/iosApp/iosApp/Contractor/ContractorFormSheet.swift +++ b/iosApp/iosApp/Contractor/ContractorFormSheet.swift @@ -284,6 +284,10 @@ struct ContractorFormSheet: View { specialtyPickerSheet } .onAppear { + // Track screen view for new contractors + if contractor == nil { + PostHogAnalytics.shared.screen(AnalyticsEvents.newContractorScreenShown) + } residenceViewModel.loadMyResidences() loadContractorData() } @@ -493,6 +497,8 @@ struct ContractorFormSheet: View { viewModel.createContractor(request: request) { success in if success { + // Track contractor creation + PostHogAnalytics.shared.capture(AnalyticsEvents.contractorCreated) onSave() dismiss() } diff --git a/iosApp/iosApp/Contractor/ContractorSharingManager.swift b/iosApp/iosApp/Contractor/ContractorSharingManager.swift index 6d3d56d..ebb26b5 100644 --- a/iosApp/iosApp/Contractor/ContractorSharingManager.swift +++ b/iosApp/iosApp/Contractor/ContractorSharingManager.swift @@ -59,6 +59,8 @@ class ContractorSharingManager: ObservableObject { do { try jsonData.write(to: tempURL) + // Track contractor shared event + PostHogAnalytics.shared.capture(AnalyticsEvents.contractorShared) return tempURL } catch { print("ContractorSharingManager: Failed to write .casera file: \(error)") diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index ac953ad..2252109 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -144,7 +144,10 @@ struct ContractorsListView: View { // Add Button (disabled when showing upgrade screen) Button(action: { - if subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors") { + let currentCount = viewModel.contractors.count + if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") { + // Track paywall shown + PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount]) showingUpgradePrompt = true } else { showingAddSheet = true @@ -171,6 +174,7 @@ struct ContractorsListView: View { UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt) } .onAppear { + PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown) loadContractors() } // No need for onChange on searchText - filtering is client-side diff --git a/iosApp/iosApp/Documents/DocumentFormView.swift b/iosApp/iosApp/Documents/DocumentFormView.swift index 777eaf5..8878947 100644 --- a/iosApp/iosApp/Documents/DocumentFormView.swift +++ b/iosApp/iosApp/Documents/DocumentFormView.swift @@ -243,6 +243,11 @@ struct DocumentFormView: View { } } .onAppear { + // Track screen view for new documents + if !isEditMode { + let docType = isWarranty ? "warranty" : "document" + PostHogAnalytics.shared.screen(AnalyticsEvents.newDocumentScreenShown, properties: ["type": docType]) + } if needsResidenceSelection { residenceViewModel.loadMyResidences() } @@ -491,6 +496,9 @@ struct DocumentFormView: View { ) { success, error in isProcessing = false if success { + // Track document creation + let docType = isWarranty ? "warranty" : "document" + PostHogAnalytics.shared.capture(AnalyticsEvents.documentCreated, properties: ["type": docType]) // Reload documents to show new item documentViewModel.loadDocuments(residenceId: actualResidenceId) isPresented = false diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index 1effdde..ec059a7 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -179,6 +179,8 @@ struct DocumentsWarrantiesView: View { // Check LIVE document count before adding let currentCount = documentViewModel.documents.count if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "documents") { + // Track paywall shown + PostHogAnalytics.shared.capture(AnalyticsEvents.documentsPaywallShown, properties: ["current_count": currentCount]) showingUpgradePrompt = true } else { showAddSheet = true @@ -192,6 +194,8 @@ struct DocumentsWarrantiesView: View { } } .onAppear { + // Track screen view + PostHogAnalytics.shared.screen(AnalyticsEvents.documentsScreenShown) // Load all documents once - filtering is client-side loadAllDocuments() } diff --git a/iosApp/iosApp/Login/AppleSignInViewModel.swift b/iosApp/iosApp/Login/AppleSignInViewModel.swift index df6c016..a715adb 100644 --- a/iosApp/iosApp/Login/AppleSignInViewModel.swift +++ b/iosApp/iosApp/Login/AppleSignInViewModel.swift @@ -85,6 +85,15 @@ class AppleSignInViewModel: ObservableObject { // - Initializes lookups // - Prefetches all data + // Track Apple Sign In + PostHogAnalytics.shared.capture(AnalyticsEvents.userSignedInApple, properties: [ + "is_new_user": isNewUser + ]) + PostHogAnalytics.shared.identify( + String(user.id), + properties: ["email": user.email ?? "", "username": user.username] + ) + print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)") // Call success callback with verification status diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift index 39d1343..67397ec 100644 --- a/iosApp/iosApp/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -73,6 +73,13 @@ class LoginViewModel: ObservableObject { print("Login successful!") print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)") + // Track successful login + PostHogAnalytics.shared.capture(AnalyticsEvents.userSignedIn, properties: ["method": "email"]) + PostHogAnalytics.shared.identify( + String(response.user.id), + properties: ["email": response.user.email ?? "", "username": response.user.username ?? ""] + ) + // Initialize lookups via APILayer Task { _ = try? await APILayer.shared.initializeLookups() @@ -117,6 +124,9 @@ class LoginViewModel: ObservableObject { // APILayer.logout clears DataManager try? await APILayer.shared.logout() + // Reset PostHog user identity + PostHogAnalytics.shared.reset() + // Clear widget task data WidgetDataManager.shared.clearCache() diff --git a/iosApp/iosApp/Profile/NotificationPreferencesView.swift b/iosApp/iosApp/Profile/NotificationPreferencesView.swift index 1303bfe..6753a9c 100644 --- a/iosApp/iosApp/Profile/NotificationPreferencesView.swift +++ b/iosApp/iosApp/Profile/NotificationPreferencesView.swift @@ -297,6 +297,8 @@ struct NotificationPreferencesView: View { } } .onAppear { + // Track screen view + PostHogAnalytics.shared.screen(AnalyticsEvents.notificationSettingsScreenShown) viewModel.loadPreferences() } } diff --git a/iosApp/iosApp/Profile/ProfileTabView.swift b/iosApp/iosApp/Profile/ProfileTabView.swift index 36f53c2..c7b5243 100644 --- a/iosApp/iosApp/Profile/ProfileTabView.swift +++ b/iosApp/iosApp/Profile/ProfileTabView.swift @@ -208,6 +208,9 @@ struct ProfileTabView: View { } message: { Text(L10n.Profile.purchasesRestoredMessage) } + .onAppear { + PostHogAnalytics.shared.screen(AnalyticsEvents.settingsScreenShown) + } } private func sendSupportEmail() { diff --git a/iosApp/iosApp/Profile/ThemeSelectionView.swift b/iosApp/iosApp/Profile/ThemeSelectionView.swift index 5cf6080..40299c1 100644 --- a/iosApp/iosApp/Profile/ThemeSelectionView.swift +++ b/iosApp/iosApp/Profile/ThemeSelectionView.swift @@ -39,6 +39,9 @@ struct ThemeSelectionView: View { let generator = UIImpactFeedbackGenerator(style: .medium) generator.impactOccurred() + // Track theme change + PostHogAnalytics.shared.capture(AnalyticsEvents.themeChanged, properties: ["theme": theme.rawValue]) + // Update theme with animation themeManager.setTheme(theme) } diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift index dfb2ea6..2e10710 100644 --- a/iosApp/iosApp/Register/RegisterView.swift +++ b/iosApp/iosApp/Register/RegisterView.swift @@ -152,6 +152,9 @@ struct RegisterView: View { error: viewModel.errorMessage, onRetry: { viewModel.register() } ) + .onAppear { + PostHogAnalytics.shared.screen(AnalyticsEvents.registrationScreenShown) + } } } } diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift index cc94ceb..cb67c6e 100644 --- a/iosApp/iosApp/Register/RegisterViewModel.swift +++ b/iosApp/iosApp/Register/RegisterViewModel.swift @@ -49,12 +49,19 @@ class RegisterViewModel: ObservableObject { let request = RegisterRequest(username: username, email: email, password: password, firstName: nil, lastName: nil) let result = try await APILayer.shared.register(request: request) - if let success = result as? ApiResultSuccess, let _ = success.data { + if let success = result as? ApiResultSuccess, let response = success.data { // APILayer.register() now handles: // - Setting auth token in DataManager // - Storing token in TokenManager // - Initializing lookups + // Track successful registration + PostHogAnalytics.shared.capture(AnalyticsEvents.userRegistered, properties: ["method": "email"]) + PostHogAnalytics.shared.identify( + String(response.user.id), + properties: ["email": response.user.email ?? "", "username": response.user.username ?? ""] + ) + // Update AuthenticationManager - user is authenticated but NOT verified AuthenticationManager.shared.login(verified: false) diff --git a/iosApp/iosApp/Residence/ResidenceSharingManager.swift b/iosApp/iosApp/Residence/ResidenceSharingManager.swift index 7ffd279..91bcdad 100644 --- a/iosApp/iosApp/Residence/ResidenceSharingManager.swift +++ b/iosApp/iosApp/Residence/ResidenceSharingManager.swift @@ -89,6 +89,8 @@ class ResidenceSharingManager: ObservableObject { do { try jsonData.write(to: tempURL) + // Track residence shared event + PostHogAnalytics.shared.capture(AnalyticsEvents.residenceShared, properties: ["method": "file"]) return tempURL } catch { print("ResidenceSharingManager: Failed to write .casera file: \(error)") diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 1a1edd1..ae3182e 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -111,6 +111,7 @@ struct ResidencesListView: View { } } .onAppear { + PostHogAnalytics.shared.screen(AnalyticsEvents.residenceScreenShown) if authManager.isAuthenticated { viewModel.loadMyResidences() } diff --git a/iosApp/iosApp/ResidenceFormView.swift b/iosApp/iosApp/ResidenceFormView.swift index 8fca418..5f6be63 100644 --- a/iosApp/iosApp/ResidenceFormView.swift +++ b/iosApp/iosApp/ResidenceFormView.swift @@ -230,6 +230,9 @@ struct ResidenceFormView: View { } } .onAppear { + if !isEditMode { + PostHogAnalytics.shared.screen(AnalyticsEvents.newResidenceScreenShown) + } loadResidenceTypes() initializeForm() if isEditMode && isCurrentUserOwner { @@ -368,6 +371,10 @@ struct ResidenceFormView: View { // Add mode viewModel.createResidence(request: request) { success in if success { + // Track residence created + PostHogAnalytics.shared.capture(AnalyticsEvents.residenceCreated, properties: [ + "residence_type": selectedPropertyType?.name ?? "unknown" + ]) onSuccess?() isPresented = false } diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 80ab70e..177c7c2 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -97,6 +97,7 @@ struct AllTasksView: View { } } .onAppear { + PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown) loadAllTasks() residenceViewModel.loadMyResidences() } diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index 6896825..b8da411 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -312,6 +312,10 @@ struct TaskFormView: View { } } .onAppear { + // Track screen view for new tasks + if !isEditMode { + PostHogAnalytics.shared.screen(AnalyticsEvents.newTaskScreenShown) + } // Set defaults when lookups are available if dataManager.lookupsInitialized { setDefaults() @@ -520,6 +524,8 @@ struct TaskFormView: View { viewModel.createTask(request: request) { success in if success { + // Track task creation + PostHogAnalytics.shared.capture(AnalyticsEvents.taskCreated, properties: ["residence_id": actualResidenceId]) // View will dismiss automatically via onChange } } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 6829f92..72fbcfe 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -32,6 +32,9 @@ struct iOSApp: App { // Initialize TokenStorage once at app startup (legacy support) TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance()) + // Initialize PostHog Analytics + PostHogAnalytics.shared.initialize() + // Initialize lookups at app start (public endpoints, no auth required) // This fetches /static_data/ and /upgrade-triggers/ immediately Task {