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:
@@ -82,13 +82,11 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
let userInfo = notification.request.content.userInfo
|
||||
print("📬 Notification received in foreground: \(userInfo)")
|
||||
let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted()
|
||||
print("📬 Notification received in foreground. Keys: \(payloadKeys)")
|
||||
|
||||
Task { @MainActor in
|
||||
PushNotificationManager.shared.handleNotification(userInfo: userInfo)
|
||||
}
|
||||
|
||||
// Show notification even when app is in foreground
|
||||
// Passive mode in foreground: present banner/sound, but do not
|
||||
// mutate read state or trigger navigation automatically.
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
}
|
||||
|
||||
@@ -102,7 +100,8 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
let actionIdentifier = response.actionIdentifier
|
||||
|
||||
print("👆 User interacted with notification - Action: \(actionIdentifier)")
|
||||
print(" UserInfo: \(userInfo)")
|
||||
let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted()
|
||||
print(" Payload keys: \(payloadKeys)")
|
||||
|
||||
Task { @MainActor in
|
||||
// Handle action buttons or default tap
|
||||
|
||||
@@ -11,8 +11,11 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
|
||||
/// Pending task ID to navigate to (set when notification is tapped before app is ready)
|
||||
@Published var pendingNavigationTaskId: Int?
|
||||
@Published var pendingNavigationResidenceId: Int?
|
||||
@Published var pendingNavigationDocumentId: Int?
|
||||
|
||||
private let registeredTokenKey = "com.casera.registeredDeviceToken"
|
||||
private let registeredUserIdKey = "com.casera.registeredUserId"
|
||||
|
||||
/// The last token that was successfully registered with the backend
|
||||
private var lastRegisteredToken: String? {
|
||||
@@ -20,6 +23,12 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
set { UserDefaults.standard.set(newValue, forKey: registeredTokenKey) }
|
||||
}
|
||||
|
||||
/// The user ID associated with the last successful registration
|
||||
private var lastRegisteredUserId: String? {
|
||||
get { UserDefaults.standard.string(forKey: registeredUserIdKey) }
|
||||
set { UserDefaults.standard.set(newValue, forKey: registeredUserIdKey) }
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
@@ -55,7 +64,8 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
|
||||
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
self.deviceToken = tokenString
|
||||
print("📱 APNs device token: \(tokenString)")
|
||||
let redactedToken = "\(tokenString.prefix(8))...\(tokenString.suffix(8))"
|
||||
print("📱 APNs device token: \(redactedToken)")
|
||||
|
||||
// Register with backend
|
||||
Task {
|
||||
@@ -88,12 +98,6 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if token hasn't changed
|
||||
if token == lastRegisteredToken {
|
||||
print("📱 Device token unchanged, skipping registration")
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await registerDeviceWithBackend(token: token, force: false)
|
||||
}
|
||||
@@ -105,10 +109,21 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if token hasn't changed (unless forced)
|
||||
if !force && token == lastRegisteredToken {
|
||||
print("📱 Device token unchanged, skipping registration")
|
||||
return
|
||||
let currentUserId = await MainActor.run {
|
||||
DataManagerObservable.shared.currentUser.map { String($0.id) }
|
||||
}
|
||||
|
||||
// Skip only if both token and user identity match.
|
||||
if !force, token == lastRegisteredToken {
|
||||
if let currentUserId, currentUserId == lastRegisteredUserId {
|
||||
print("📱 Device token already registered for current user, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
if currentUserId == nil, lastRegisteredUserId == nil {
|
||||
print("📱 Device token already registered, skipping")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique device identifier
|
||||
@@ -130,12 +145,17 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
let result = try await APILayer.shared.registerDevice(request: request)
|
||||
|
||||
if let success = result as? ApiResultSuccess<DeviceRegistrationResponse> {
|
||||
print("✅ Device registered successfully: \(success.data)")
|
||||
if success.data != nil {
|
||||
print("✅ Device registered successfully")
|
||||
} else {
|
||||
print("✅ Device registration acknowledged")
|
||||
}
|
||||
// Cache the token on successful registration
|
||||
await MainActor.run {
|
||||
self.lastRegisteredToken = token
|
||||
self.lastRegisteredUserId = currentUserId
|
||||
}
|
||||
} else if let error = result as? ApiResultError {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
print("❌ Failed to register device: \(error.message)")
|
||||
} else {
|
||||
print("⚠️ Unexpected result type from device registration")
|
||||
@@ -148,10 +168,10 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
// MARK: - Handle Notifications
|
||||
|
||||
func handleNotification(userInfo: [AnyHashable: Any]) {
|
||||
print("📬 Received notification: \(userInfo)")
|
||||
print("📬 Received notification: \(redactedPayloadSummary(userInfo: userInfo))")
|
||||
|
||||
// Extract notification data
|
||||
if let notificationId = userInfo["notification_id"] as? String {
|
||||
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
|
||||
print("Notification ID: \(notificationId)")
|
||||
|
||||
// Mark as read when user taps notification
|
||||
@@ -171,7 +191,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
print("📬 Handling notification tap")
|
||||
|
||||
// Mark as read
|
||||
if let notificationId = userInfo["notification_id"] as? String {
|
||||
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
|
||||
Task {
|
||||
await markNotificationAsRead(notificationId: notificationId)
|
||||
}
|
||||
@@ -188,10 +208,13 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
|
||||
if canNavigateToTask {
|
||||
// Navigate to task detail
|
||||
if let taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) {
|
||||
navigateToTask(taskId: taskId)
|
||||
} else if let taskId = userInfo["task_id"] as? Int {
|
||||
if let taskId = intValue(for: "task_id", in: userInfo) {
|
||||
navigateToTask(taskId: taskId)
|
||||
} else if let type = userInfo["type"] as? String {
|
||||
// Fall back to type-specific routing for non-task notifications.
|
||||
handleNotificationType(type: type, userInfo: userInfo)
|
||||
} else {
|
||||
navigateToHome()
|
||||
}
|
||||
} else {
|
||||
// Free user with limitations enabled - navigate to home
|
||||
@@ -204,7 +227,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
print("🔘 Handling notification action: \(actionIdentifier)")
|
||||
|
||||
// Mark as read
|
||||
if let notificationId = userInfo["notification_id"] as? String {
|
||||
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
|
||||
Task {
|
||||
await markNotificationAsRead(notificationId: notificationId)
|
||||
}
|
||||
@@ -224,14 +247,7 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
// Extract task ID
|
||||
var taskId: Int?
|
||||
if let taskIdStr = userInfo["task_id"] as? String {
|
||||
taskId = Int(taskIdStr)
|
||||
} else if let id = userInfo["task_id"] as? Int {
|
||||
taskId = id
|
||||
}
|
||||
|
||||
guard let taskId = taskId else {
|
||||
guard let taskId = intValue(for: "task_id", in: userInfo) else {
|
||||
print("❌ No task_id found in notification")
|
||||
return
|
||||
}
|
||||
@@ -265,24 +281,27 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
private func handleNotificationType(type: String, userInfo: [AnyHashable: Any]) {
|
||||
switch type {
|
||||
case "task_due_soon", "task_overdue", "task_completed", "task_assigned":
|
||||
if let taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) {
|
||||
print("Task notification for task ID: \(taskId)")
|
||||
navigateToTask(taskId: taskId)
|
||||
} else if let taskId = userInfo["task_id"] as? Int {
|
||||
if let taskId = intValue(for: "task_id", in: userInfo) {
|
||||
print("Task notification for task ID: \(taskId)")
|
||||
navigateToTask(taskId: taskId)
|
||||
}
|
||||
|
||||
case "residence_shared":
|
||||
if let residenceId = userInfo["residence_id"] as? String {
|
||||
if let residenceId = intValue(for: "residence_id", in: userInfo) {
|
||||
print("Residence shared notification for residence ID: \(residenceId)")
|
||||
// TODO: Navigate to residence detail
|
||||
navigateToResidence(residenceId: residenceId)
|
||||
} else {
|
||||
print("Residence shared notification without residence ID")
|
||||
navigateToResidencesTab()
|
||||
}
|
||||
|
||||
case "warranty_expiring":
|
||||
if let documentId = userInfo["document_id"] as? String {
|
||||
if let documentId = intValue(for: "document_id", in: userInfo) {
|
||||
print("Warranty expiring notification for document ID: \(documentId)")
|
||||
// TODO: Navigate to document detail
|
||||
navigateToDocument(documentId: documentId)
|
||||
} else {
|
||||
print("Warranty expiring notification without document ID")
|
||||
navigateToDocumentsTab()
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -313,8 +332,10 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||
}
|
||||
} else if let error = result as? ApiResultError {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
print("❌ Failed to complete task: \(error.message)")
|
||||
} else {
|
||||
print("⚠️ Unexpected result while completing task \(taskId)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ Error completing task: \(error.localizedDescription)")
|
||||
@@ -333,8 +354,10 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||
}
|
||||
} else if let error = result as? ApiResultError {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
print("❌ Failed to mark task in progress: \(error.message)")
|
||||
} else {
|
||||
print("⚠️ Unexpected result while marking task \(taskId) in progress")
|
||||
}
|
||||
} catch {
|
||||
print("❌ Error marking task in progress: \(error.localizedDescription)")
|
||||
@@ -353,8 +376,10 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||
}
|
||||
} else if let error = result as? ApiResultError {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
print("❌ Failed to cancel task: \(error.message)")
|
||||
} else {
|
||||
print("⚠️ Unexpected result while cancelling task \(taskId)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ Error cancelling task: \(error.localizedDescription)")
|
||||
@@ -373,8 +398,10 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||
}
|
||||
} else if let error = result as? ApiResultError {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
print("❌ Failed to uncancel task: \(error.message)")
|
||||
} else {
|
||||
print("⚠️ Unexpected result while uncancelling task \(taskId)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ Error uncancelling task: \(error.localizedDescription)")
|
||||
@@ -398,6 +425,14 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
/// Clear pending navigation after it has been handled
|
||||
func clearPendingNavigation() {
|
||||
pendingNavigationTaskId = nil
|
||||
pendingNavigationResidenceId = nil
|
||||
pendingNavigationDocumentId = nil
|
||||
}
|
||||
|
||||
/// Clear device registration cache (call on logout/account switch).
|
||||
func clearRegistrationCache() {
|
||||
lastRegisteredToken = nil
|
||||
lastRegisteredUserId = nil
|
||||
}
|
||||
|
||||
private func navigateToEditTask(taskId: Int) {
|
||||
@@ -409,11 +444,71 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
private func navigateToResidence(residenceId: Int) {
|
||||
print("🏠 Navigating to residence \(residenceId)")
|
||||
pendingNavigationResidenceId = residenceId
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToResidence,
|
||||
object: nil,
|
||||
userInfo: ["residenceId": residenceId]
|
||||
)
|
||||
}
|
||||
|
||||
private func navigateToResidencesTab() {
|
||||
NotificationCenter.default.post(name: .navigateToResidence, object: nil)
|
||||
}
|
||||
|
||||
private func navigateToDocument(documentId: Int) {
|
||||
print("📄 Navigating to document \(documentId)")
|
||||
pendingNavigationDocumentId = documentId
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToDocument,
|
||||
object: nil,
|
||||
userInfo: ["documentId": documentId]
|
||||
)
|
||||
}
|
||||
|
||||
private func navigateToDocumentsTab() {
|
||||
NotificationCenter.default.post(name: .navigateToDocument, object: nil)
|
||||
}
|
||||
|
||||
private func navigateToHome() {
|
||||
print("🏠 Navigating to home")
|
||||
pendingNavigationTaskId = nil
|
||||
pendingNavigationResidenceId = nil
|
||||
pendingNavigationDocumentId = nil
|
||||
NotificationCenter.default.post(name: .navigateToHome, object: nil)
|
||||
}
|
||||
|
||||
private func stringValue(for key: String, in userInfo: [AnyHashable: Any]) -> String? {
|
||||
if let value = userInfo[key] as? String {
|
||||
return value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
}
|
||||
if let value = userInfo[key] as? NSNumber {
|
||||
return value.stringValue
|
||||
}
|
||||
if let value = userInfo[key] as? Int {
|
||||
return String(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func intValue(for key: String, in userInfo: [AnyHashable: Any]) -> Int? {
|
||||
guard let raw = stringValue(for: key, in: userInfo) else {
|
||||
return nil
|
||||
}
|
||||
return Int(raw)
|
||||
}
|
||||
|
||||
private func redactedPayloadSummary(userInfo: [AnyHashable: Any]) -> String {
|
||||
let type = stringValue(for: "type", in: userInfo) ?? "unknown"
|
||||
let notificationId = stringValue(for: "notification_id", in: userInfo) ?? "missing"
|
||||
let taskId = stringValue(for: "task_id", in: userInfo) ?? "none"
|
||||
let residenceId = stringValue(for: "residence_id", in: userInfo) ?? "none"
|
||||
let documentId = stringValue(for: "document_id", in: userInfo) ?? "none"
|
||||
return "type=\(type), notification_id=\(notificationId), task_id=\(taskId), residence_id=\(residenceId), document_id=\(documentId)"
|
||||
}
|
||||
|
||||
private func markNotificationAsRead(notificationId: String) async {
|
||||
guard TokenStorage.shared.getToken() != nil,
|
||||
let notificationIdInt = Int32(notificationId) else {
|
||||
@@ -425,8 +520,10 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
|
||||
if result is ApiResultSuccess<ComposeApp.Notification> {
|
||||
print("✅ Notification marked as read")
|
||||
} else if let error = result as? ApiResultError {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
print("❌ Failed to mark notification as read: \(error.message)")
|
||||
} else {
|
||||
print("⚠️ Unexpected result while marking notification read")
|
||||
}
|
||||
} catch {
|
||||
print("❌ Error marking notification as read: \(error.localizedDescription)")
|
||||
@@ -447,10 +544,11 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
if result is ApiResultSuccess<NotificationPreference> {
|
||||
print("✅ Notification preferences updated")
|
||||
return true
|
||||
} else if let error = result as? ApiResultError {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
print("❌ Failed to update preferences: \(error.message)")
|
||||
return false
|
||||
}
|
||||
print("⚠️ Unexpected result while updating notification preferences")
|
||||
return false
|
||||
} catch {
|
||||
print("❌ Error updating notification preferences: \(error.localizedDescription)")
|
||||
@@ -469,10 +567,11 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
|
||||
if let success = result as? ApiResultSuccess<NotificationPreference> {
|
||||
return success.data
|
||||
} else if let error = result as? ApiResultError {
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
print("❌ Failed to get preferences: \(error.message)")
|
||||
return nil
|
||||
}
|
||||
print("⚠️ Unexpected result while loading notification preferences")
|
||||
return nil
|
||||
} catch {
|
||||
print("❌ Error getting notification preferences: \(error.localizedDescription)")
|
||||
@@ -490,4 +589,3 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
UNUserNotificationCenter.current().setBadgeCount(count)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user