Replace PostHog integration with AnalyticsManager architecture

Remove old PostHogAnalytics singleton and replace with guide-based
two-file architecture: AnalyticsManager (singleton wrapper with super
properties, session replay, opt-out, subscription funnel) and
AnalyticsEvent (type-safe enum with associated values).

Key changes:
- New API key, self-hosted analytics endpoint
- All 19 events ported to type-safe AnalyticsEvent enum
- Screen tracking via AnalyticsManager.Screen enum + SwiftUI modifier
- Remove all identify() calls — fully anonymous analytics
- Add lifecycle hooks: flush on background, update super properties on foreground
- Add privacy opt-out toggle in Settings
- Subscription funnel methods ready for IAP integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-11 09:48:49 -06:00
parent 09be5fa444
commit 2fc4a48fc9
50 changed files with 2191 additions and 335 deletions

View File

@@ -131,7 +131,7 @@ struct ManageUsersView: View {
let responseData = successResult.data as? [ResidenceUserResponse] {
self.users = responseData
self.isLoading = false
} else if let errorResult = result as? ApiResultError {
} else if let errorResult = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
self.isLoading = false
} else {
@@ -181,7 +181,7 @@ struct ManageUsersView: View {
let response = successResult.data {
self.shareCode = response.shareCode
self.isGeneratingCode = false
} else if let errorResult = result as? ApiResultError {
} else if let errorResult = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
self.isGeneratingCode = false
} else {
@@ -209,7 +209,7 @@ struct ManageUsersView: View {
if result is ApiResultSuccess<RemoveUserResponse> {
// Remove user from local list
self.users.removeAll { $0.id == userId }
} else if let errorResult = result as? ApiResultError {
} else if let errorResult = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
} else {
self.errorMessage = "Failed to remove user"

View File

@@ -246,6 +246,8 @@ private extension ResidenceDetailView {
selectedTaskForComplete: $selectedTaskForComplete,
selectedTaskForArchive: $selectedTaskForArchive,
showArchiveConfirmation: $showArchiveConfirmation,
selectedTaskForCancel: $selectedTaskForCancel,
showCancelConfirmation: $showCancelConfirmation,
reloadTasks: { loadResidenceTasks(forceRefresh: true) }
)
} else if isLoadingTasks {
@@ -436,7 +438,7 @@ private extension ResidenceDetailView {
if result is ApiResultSuccess<KotlinUnit> {
dismiss()
} else if let errorResult = result as? ApiResultError {
} else if let errorResult = ApiResultBridge.error(from: result) {
self.viewModel.errorMessage = ErrorMessageParser.parse(errorResult.message)
} else {
self.viewModel.errorMessage = "Failed to delete residence"
@@ -468,7 +470,7 @@ private extension ResidenceDetailView {
if let successResult = result as? ApiResultSuccess<NSArray> {
self.contractors = (successResult.data as? [ContractorSummary]) ?? []
self.isLoadingContractors = false
} else if let errorResult = result as? ApiResultError {
} else if let errorResult = ApiResultBridge.error(from: result) {
self.contractorsError = errorResult.message
self.isLoadingContractors = false
} else {
@@ -495,6 +497,8 @@ private struct TasksSectionContainer: View {
@Binding var selectedTaskForComplete: TaskResponse?
@Binding var selectedTaskForArchive: TaskResponse?
@Binding var showArchiveConfirmation: Bool
@Binding var selectedTaskForCancel: TaskResponse?
@Binding var showCancelConfirmation: Bool
let reloadTasks: () -> Void

View File

@@ -62,7 +62,7 @@ class ResidenceSharingManager: ObservableObject {
guard let success = result as? ApiResultSuccess<SharedResidence>,
let sharedResidence = success.data else {
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
errorMessage = ErrorMessageParser.parse(error.message)
} else {
errorMessage = "Failed to generate share code"
@@ -90,7 +90,7 @@ class ResidenceSharingManager: ObservableObject {
do {
try jsonData.write(to: tempURL)
// Track residence shared event
PostHogAnalytics.shared.capture(AnalyticsEvents.residenceShared, properties: ["method": "file"])
AnalyticsManager.shared.track(.residenceShared(method: "file"))
return tempURL
} catch {
print("ResidenceSharingManager: Failed to write .casera file: \(error)")
@@ -141,7 +141,7 @@ class ResidenceSharingManager: ObservableObject {
self.importSuccess = true
self.isImporting = false
completion(true)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isImporting = false
completion(false)

View File

@@ -80,7 +80,7 @@ class ResidenceViewModel: ObservableObject {
let result = try await APILayer.shared.getSummary(forceRefresh: forceRefresh)
// Only handle errors - success updates DataManager automatically
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
}
self.isLoading = false
@@ -110,7 +110,7 @@ class ResidenceViewModel: ObservableObject {
let result = try await APILayer.shared.getMyResidences(forceRefresh: forceRefresh)
// Only handle errors - success updates DataManager automatically
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
}
@@ -132,9 +132,12 @@ class ResidenceViewModel: ObservableObject {
if let success = result as? ApiResultSuccess<ResidenceResponse> {
self.selectedResidence = success.data
self.isLoading = false
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
} else {
self.errorMessage = "Failed to load residence"
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -172,7 +175,7 @@ class ResidenceViewModel: ObservableObject {
self.isLoading = false
completion(nil)
}
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("🏠 ResidenceVM: Is ApiResultError: \(error.message ?? "nil")")
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
@@ -209,10 +212,14 @@ class ResidenceViewModel: ObservableObject {
// which updates DataManagerObservable, which updates our @Published
// myResidences via Combine subscription
completion(true)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
} else {
self.errorMessage = "Failed to update residence"
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -233,9 +240,12 @@ class ResidenceViewModel: ObservableObject {
if let success = result as? ApiResultSuccess<GenerateReportResponse> {
self.reportMessage = success.data?.message ?? "Report generated, but no message returned."
self.isGeneratingReport = false
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.reportMessage = ErrorMessageParser.parse(error.message)
self.isGeneratingReport = false
} else {
self.reportMessage = "Failed to generate report"
self.isGeneratingReport = false
}
} catch {
self.reportMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -267,10 +277,14 @@ class ResidenceViewModel: ObservableObject {
// which updates DataManagerObservable, which updates our
// @Published myResidences via Combine subscription
completion(true)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
} else {
self.errorMessage = "Failed to join residence"
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)

View File

@@ -8,6 +8,8 @@ struct ResidencesListView: View {
@State private var showingJoinResidence = false
@State private var showingUpgradePrompt = false
@State private var showingSettings = false
@State private var pushTargetResidenceId: Int32?
@State private var navigateToPushResidence = false
@StateObject private var authManager = AuthenticationManager.shared
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@Environment(\.scenePhase) private var scenePhase
@@ -103,7 +105,10 @@ struct ResidencesListView: View {
}
}
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.residenceScreenShown)
AnalyticsManager.shared.trackScreen(.residences)
if let pendingResidenceId = PushNotificationManager.shared.pendingNavigationResidenceId {
navigateToResidenceFromPush(residenceId: pendingResidenceId)
}
if authManager.isAuthenticated {
viewModel.loadMyResidences()
// Also load tasks to populate summary stats
@@ -135,6 +140,32 @@ struct ResidencesListView: View {
viewModel.myResidences = nil
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { notification in
if let residenceId = notification.userInfo?["residenceId"] as? Int {
navigateToResidenceFromPush(residenceId: residenceId)
}
}
.background(
NavigationLink(
destination: Group {
if let residenceId = pushTargetResidenceId {
ResidenceDetailView(residenceId: residenceId)
} else {
EmptyView()
}
},
isActive: $navigateToPushResidence
) {
EmptyView()
}
.hidden()
)
}
private func navigateToResidenceFromPush(residenceId: Int) {
pushTargetResidenceId = Int32(residenceId)
navigateToPushResidence = true
PushNotificationManager.shared.pendingNavigationResidenceId = nil
}
}