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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
import Foundation
enum AnalyticsEvent {
// MARK: - Authentication
case userSignedIn(method: String)
case userSignedInApple(isNewUser: Bool)
case userRegistered(method: String)
// MARK: - Residence
case residenceCreated(type: String)
case residenceLimitReached
case residenceShared(method: String)
case shareResidencePaywallShown
// MARK: - Task
case taskCreated(residenceId: Int32)
// MARK: - Contractor
case contractorCreated
case contractorShared
case contractorPaywallShown(currentCount: Int)
case shareContractorPaywallShown
// MARK: - Documents
case documentCreated(type: String)
case documentsPaywallShown(currentCount: Int)
// MARK: - Settings
case themeChanged(theme: String)
case analyticsToggled(enabled: Bool)
// MARK: - Errors
case errorOccurred(domain: String, message: String, screen: String?)
// MARK: - Payload
var payload: (String, [String: Any]?) {
switch self {
// Authentication
case .userSignedIn(let method):
return ("user_signed_in", ["method": method])
case .userSignedInApple(let isNewUser):
return ("user_signed_in_apple", ["is_new_user": isNewUser])
case .userRegistered(let method):
return ("user_registered", ["method": method])
// Residence
case .residenceCreated(let type):
return ("residence_created", ["residence_type": type])
case .residenceLimitReached:
return ("residence_limit_reached", nil)
case .residenceShared(let method):
return ("residence_shared", ["method": method])
case .shareResidencePaywallShown:
return ("share_residence_paywall_shown", nil)
// Task
case .taskCreated(let residenceId):
return ("task_created", ["residence_id": residenceId])
// Contractor
case .contractorCreated:
return ("contractor_created", nil)
case .contractorShared:
return ("contractor_shared", nil)
case .contractorPaywallShown(let currentCount):
return ("contractor_paywall_shown", ["current_count": currentCount])
case .shareContractorPaywallShown:
return ("share_contractor_paywall_shown", nil)
// Documents
case .documentCreated(let type):
return ("document_created", ["type": type])
case .documentsPaywallShown(let currentCount):
return ("documents_paywall_shown", ["current_count": currentCount])
// Settings
case .themeChanged(let theme):
return ("theme_changed", ["theme": theme])
case .analyticsToggled(let enabled):
return ("analytics_toggled", ["enabled": enabled])
// Errors
case .errorOccurred(let domain, let message, let screen):
var props: [String: Any] = ["domain": domain, "message": message]
if let screen { props["screen"] = screen }
return ("error_occurred", props)
}
}
}

View File

@@ -0,0 +1,286 @@
import Foundation
import PostHog
import SwiftUI
import UIKit
@MainActor
final class AnalyticsManager {
// MARK: - Singleton
static let shared = AnalyticsManager()
private init() {}
// MARK: - Configuration
#if DEBUG
private static let apiKey = "phc_oeZddTz7Iw0NxDXFeCycS7TG9YRVtv7WP2DjOvFPUeQ"
#else
private static let apiKey = "phc_oeZddTz7Iw0NxDXFeCycS7TG9YRVtv7WP2DjOvFPUeQ"
#endif
private static let host = "https://analytics.88oakapps.com"
private static let optOutKey = "analyticsOptedOut"
private static let sessionReplayKey = "analytics_session_replay_enabled"
private var isConfigured = false
// MARK: - ISO8601 Formatter
private static let iso8601Formatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
// 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
updateSuperProperties()
}
// MARK: - Event Tracking
func track(_ event: AnalyticsEvent) {
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
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: - Flush & Reset
func flush() {
guard isConfigured else { return }
PostHogSDK.shared.flush()
}
func reset() {
guard isConfigured else { return }
PostHogSDK.shared.reset()
}
// MARK: - Super Properties
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
var props: [String: Any] = [
"app_version": version,
"build_number": build,
"device_model": device,
"os_version": osVersion,
"is_pro": SubscriptionCacheWrapper.shared.currentTier == "pro",
"animations_enabled": !UIAccessibility.isReduceMotionEnabled,
]
PostHogSDK.shared.register(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: - Subscription Status (Person Properties)
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)
updateSubscriptionPersonProperties(status, type: type)
}
private func updateSubscriptionPersonProperties(_ status: String, type: String) {
guard isConfigured else { return }
PostHogSDK.shared.capture("$set", properties: [
"$set": [
"subscription_status": status,
"subscription_type": type,
],
])
}
// MARK: - Opt-Out / Opt-In
var isOptedOut: Bool {
UserDefaults.standard.bool(forKey: Self.optOutKey)
}
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: - Session Replay Control
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()
}
}
}
}
// MARK: - Screen Enum
extension AnalyticsManager {
enum Screen: String {
case registration = "registration"
case residences = "residences"
case newResidence = "new_residence"
case tasks = "tasks"
case newTask = "new_task"
case contractors = "contractors"
case newContractor = "new_contractor"
case documents = "documents"
case newDocument = "new_document"
case settings = "settings"
case notificationSettings = "notification_settings"
case paywall = "paywall"
}
}
// MARK: - SwiftUI Screen Tracking Modifier
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))
}
}

View File

@@ -1,116 +0,0 @@
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"
}

View File

@@ -117,12 +117,13 @@ final class BackgroundTaskManager {
print("BackgroundTaskManager: Widget timelines reloaded")
return true
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("BackgroundTaskManager: API error - \(error.message)")
return false
}
return true
print("BackgroundTaskManager: Unexpected API result type during refresh")
return false
} catch {
print("BackgroundTaskManager: Error during refresh - \(error.localizedDescription)")
return false

View File

@@ -292,7 +292,7 @@ struct ContractorFormSheet: View {
.onAppear {
// Track screen view for new contractors
if contractor == nil {
PostHogAnalytics.shared.screen(AnalyticsEvents.newContractorScreenShown)
AnalyticsManager.shared.trackScreen(.newContractor)
}
residenceViewModel.loadMyResidences()
loadContractorData()
@@ -504,7 +504,7 @@ struct ContractorFormSheet: View {
viewModel.createContractor(request: request) { success in
if success {
// Track contractor creation
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorCreated)
AnalyticsManager.shared.track(.contractorCreated)
onSave()
dismiss()
}

View File

@@ -60,7 +60,7 @@ class ContractorSharingManager: ObservableObject {
do {
try jsonData.write(to: tempURL)
// Track contractor shared event
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorShared)
AnalyticsManager.shared.track(.contractorShared)
return tempURL
} catch {
print("ContractorSharingManager: Failed to write .casera file: \(error)")
@@ -116,7 +116,7 @@ class ContractorSharingManager: 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.importError = ErrorMessageParser.parse(error.message)
self.isImporting = false
completion(false)

View File

@@ -52,9 +52,12 @@ class ContractorViewModel: ObservableObject {
// API updates DataManager on success, which triggers our observation
if result is ApiResultSuccess<NSArray> {
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 contractors"
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -74,9 +77,12 @@ class ContractorViewModel: ObservableObject {
if let success = result as? ApiResultSuccess<Contractor> {
self.selectedContractor = 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 contractor details"
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -98,10 +104,14 @@ class ContractorViewModel: ObservableObject {
self.isCreating = false
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isCreating = false
completion(false)
} else {
self.errorMessage = "Failed to create contractor"
self.isCreating = false
completion(false)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -124,10 +134,14 @@ class ContractorViewModel: ObservableObject {
self.isUpdating = false
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isUpdating = false
completion(false)
} else {
self.errorMessage = "Failed to update contractor"
self.isUpdating = false
completion(false)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -150,10 +164,14 @@ class ContractorViewModel: ObservableObject {
self.isDeleting = false
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isDeleting = false
completion(false)
} else {
self.errorMessage = "Failed to delete contractor"
self.isDeleting = false
completion(false)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -171,9 +189,12 @@ class ContractorViewModel: ObservableObject {
if result is ApiResultSuccess<Contractor> {
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
} else {
self.errorMessage = "Failed to update favorite status"
completion(false)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)

View File

@@ -144,7 +144,7 @@ struct ContractorsListView: View {
Button(action: {
let currentCount = viewModel.contractors.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount])
AnalyticsManager.shared.track(.contractorPaywallShown(currentCount: currentCount))
showingUpgradePrompt = true
} else {
showingAddSheet = true
@@ -169,7 +169,7 @@ struct ContractorsListView: View {
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
}
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown)
AnalyticsManager.shared.trackScreen(.contractors)
loadContractors()
}
}

View File

@@ -0,0 +1,13 @@
import Foundation
import ComposeApp
/// Bridges Kotlin sealed ApiResult subclasses in Swift without generic-cast warnings.
enum ApiResultBridge {
static func error(from state: Any) -> ApiResultError? {
state as? ApiResultError
}
static func isError(_ state: Any) -> Bool {
error(from: state) != nil
}
}

View File

@@ -34,9 +34,9 @@ enum StateFlowObserver {
}
resetState?()
break
} else if let error = state as? ApiResultError {
} else if let error = ApiResultBridge.error(from: state) {
await MainActor.run {
let message = ErrorMessageParser.parse(error.message ?? "An unexpected error occurred")
let message = ErrorMessageParser.parse(error.message)
onError?(message)
}
resetState?()

View File

@@ -96,11 +96,27 @@ class DataManagerObservable: ObservableObject {
let authTokenTask = Task {
for await token in DataManager.shared.authToken {
await MainActor.run {
let previousToken = self.authToken
let wasAuthenticated = previousToken != nil
self.authToken = token
self.isAuthenticated = token != nil
// Token rotated/account switched without explicit logout.
if let previousToken, let token, previousToken != token {
PushNotificationManager.shared.clearRegistrationCache()
}
// Keep widget auth in sync with token lifecycle.
if let token {
WidgetDataManager.shared.saveAuthToken(token)
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
}
// Clear widget cache on logout
if token == nil {
if token == nil && wasAuthenticated {
WidgetDataManager.shared.clearCache()
WidgetDataManager.shared.clearAuthToken()
PushNotificationManager.shared.clearRegistrationCache()
}
}
}
@@ -377,11 +393,12 @@ class DataManagerObservable: ObservableObject {
return [:]
}
var result: [Int32: V] = [:]
guard let nsDict = kotlinMap as? NSDictionary else {
print("DataManagerObservable: Failed to bridge Int-keyed map to NSDictionary")
return [:]
}
// Cast to NSDictionary to avoid Swift's strict type bridging
// which can crash when iterating [KotlinInt: V] dictionaries
let nsDict = kotlinMap as! NSDictionary
var result: [Int32: V] = [:]
for key in nsDict.allKeys {
guard let value = nsDict[key], let typedValue = value as? V else { continue }
@@ -403,12 +420,26 @@ class DataManagerObservable: ObservableObject {
return [:]
}
guard let nsDict = kotlinMap as? NSDictionary else {
print("DataManagerObservable: Failed to bridge Int->Array map to NSDictionary")
return [:]
}
var result: [Int32: [V]] = [:]
let nsDict = kotlinMap as! NSDictionary
for key in nsDict.allKeys {
guard let value = nsDict[key], let typedValue = value as? [V] else { continue }
guard let value = nsDict[key] else { continue }
let typedValue: [V]
if let swiftArray = value as? [V] {
typedValue = swiftArray
} else if let nsArray = value as? NSArray {
let converted = nsArray.compactMap { $0 as? V }
guard converted.count == nsArray.count else { continue }
typedValue = converted
} else {
continue
}
if let kotlinKey = key as? KotlinInt {
result[kotlinKey.int32Value] = typedValue
@@ -422,10 +453,25 @@ class DataManagerObservable: ObservableObject {
/// Convert Kotlin Map<String, V> to Swift [String: V]
private func convertStringMap<V>(_ kotlinMap: Any?) -> [String: V] {
guard let map = kotlinMap as? [String: V] else {
guard let kotlinMap = kotlinMap else {
return [:]
}
return map
if let map = kotlinMap as? [String: V] {
return map
}
guard let nsDict = kotlinMap as? NSDictionary else {
return [:]
}
var result: [String: V] = [:]
for key in nsDict.allKeys {
guard let stringKey = key as? String,
let value = nsDict[key] as? V else { continue }
result[stringKey] = value
}
return result
}
// MARK: - Convenience Lookup Methods

View File

@@ -247,7 +247,7 @@ struct DocumentFormView: View {
// Track screen view for new documents
if !isEditMode {
let docType = isWarranty ? "warranty" : "document"
PostHogAnalytics.shared.screen(AnalyticsEvents.newDocumentScreenShown, properties: ["type": docType])
AnalyticsManager.shared.trackScreen(.newDocument, properties: ["type": docType])
}
if needsResidenceSelection {
residenceViewModel.loadMyResidences()
@@ -443,6 +443,7 @@ struct DocumentFormView: View {
documentViewModel.updateDocument(
id: Int32(docId.intValue),
title: title,
documentType: selectedDocumentType,
description: description.isEmpty ? nil : description,
category: selectedCategory,
tags: tags.isEmpty ? nil : tags,
@@ -501,7 +502,7 @@ struct DocumentFormView: View {
if success {
// Track document creation
let docType = isWarranty ? "warranty" : "document"
PostHogAnalytics.shared.capture(AnalyticsEvents.documentCreated, properties: ["type": docType])
AnalyticsManager.shared.track(.documentCreated(type: docType))
// Reload documents to show new item
documentViewModel.loadDocuments(residenceId: actualResidenceId)
isPresented = false

View File

@@ -56,9 +56,12 @@ class DocumentViewModel: ObservableObject {
// API updates DataManager on success, which triggers our observation
if result is ApiResultSuccess<NSArray> {
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 documents"
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -125,14 +128,33 @@ class DocumentViewModel: ObservableObject {
mimeTypesList: nil
)
if result is ApiResultSuccess<Document> {
if let success = result as? ApiResultSuccess<Document> {
if !images.isEmpty {
guard let documentId = success.data?.id?.int32Value else {
self.errorMessage = "Document created, but image upload could not start"
self.isLoading = false
completion(false, self.errorMessage)
return
}
if let uploadError = await self.uploadImages(documentId: documentId, images: images) {
self.errorMessage = uploadError
self.isLoading = false
completion(false, self.errorMessage)
return
}
}
self.isLoading = false
// DataManager is updated by APILayer, view updates via observation
completion(true, nil)
} 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, self.errorMessage)
} else {
self.errorMessage = "Failed to create document"
self.isLoading = false
completion(false, self.errorMessage)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -145,6 +167,7 @@ class DocumentViewModel: ObservableObject {
func updateDocument(
id: Int32,
title: String,
documentType: String,
description: String? = nil,
category: String? = nil,
tags: String? = nil,
@@ -173,7 +196,7 @@ class DocumentViewModel: ObservableObject {
let result = try await APILayer.shared.updateDocument(
id: id,
title: title,
documentType: "", // Required but not changing
documentType: documentType,
description: description,
category: category,
tags: tags,
@@ -194,13 +217,24 @@ class DocumentViewModel: ObservableObject {
)
if result is ApiResultSuccess<Document> {
if !newImages.isEmpty,
let uploadError = await self.uploadImages(documentId: id, images: newImages) {
self.errorMessage = uploadError
self.isLoading = false
completion(false, self.errorMessage)
return
}
self.isLoading = false
// DataManager is updated by APILayer, view updates via observation
completion(true, nil)
} 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, self.errorMessage)
} else {
self.errorMessage = "Failed to update document"
self.isLoading = false
completion(false, self.errorMessage)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -222,10 +256,14 @@ class DocumentViewModel: ObservableObject {
self.isLoading = false
// DataManager is updated by APILayer, view updates via observation
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 delete document"
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -247,7 +285,7 @@ class DocumentViewModel: ObservableObject {
data.append(UInt8(bitPattern: byteArray.get(index: i)))
}
return data
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
throw NSError(domain: error.message, code: error.code?.intValue ?? 0)
}
return nil
@@ -256,4 +294,43 @@ class DocumentViewModel: ObservableObject {
}
}
}
/// Uploads additional document images after create/update metadata succeeds.
private func uploadImages(documentId: Int32, images: [UIImage]) async -> String? {
for (index, image) in images.enumerated() {
guard let compressedData = ImageCompression.compressImage(image) else {
return "Failed to process image \(index + 1)"
}
let uploadResult: Any
do {
uploadResult = try await APILayer.shared.uploadDocumentImage(
documentId: documentId,
imageBytes: self.kotlinByteArray(from: compressedData),
fileName: "document_image_\(index + 1).jpg",
mimeType: "image/jpeg"
)
} catch {
return ErrorMessageParser.parse(error.localizedDescription)
}
if let error = ApiResultBridge.error(from: uploadResult) {
return ErrorMessageParser.parse(error.message)
}
if !(uploadResult is ApiResultSuccess<DocumentImage>) {
return "Failed to upload image \(index + 1)"
}
}
return nil
}
private func kotlinByteArray(from data: Data) -> KotlinByteArray {
let byteArray = KotlinByteArray(size: Int32(data.count))
for (index, byte) in data.enumerated() {
byteArray.set(index: Int32(index), value: Int8(bitPattern: byte))
}
return byteArray
}
}

View File

@@ -73,12 +73,12 @@ class DocumentViewModelWrapper: ObservableObject {
Task {
do {
let result = try await APILayer.shared.getDocuments(
residenceId: residenceId != nil ? KotlinInt(int: residenceId!) : nil,
residenceId: residenceId.asKotlin,
documentType: documentType,
category: category,
contractorId: contractorId != nil ? KotlinInt(int: contractorId!) : nil,
isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil,
expiringSoon: expiringSoon != nil ? KotlinInt(int: expiringSoon!) : nil,
contractorId: contractorId.asKotlin,
isActive: isActive.asKotlin,
expiringSoon: expiringSoon.asKotlin,
tags: tags,
search: search,
forceRefresh: false
@@ -88,8 +88,10 @@ class DocumentViewModelWrapper: ObservableObject {
if let success = result as? ApiResultSuccess<NSArray> {
let documents = success.data as? [Document] ?? []
self.documentsState = DocumentStateSuccess(documents: documents)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.documentsState = DocumentStateError(message: error.message)
} else {
self.documentsState = DocumentStateError(message: "Failed to load documents")
}
}
} catch {
@@ -112,8 +114,10 @@ class DocumentViewModelWrapper: ObservableObject {
await MainActor.run {
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.documentDetailState = DocumentDetailStateError(message: error.message)
} else {
self.documentDetailState = DocumentDetailStateError(message: "Failed to load document")
}
}
} catch {
@@ -179,8 +183,10 @@ class DocumentViewModelWrapper: ObservableObject {
self.updateState = UpdateStateSuccess(document: document)
// Also refresh the detail state
self.documentDetailState = DocumentDetailStateSuccess(document: document)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.updateState = UpdateStateError(message: error.message)
} else {
self.updateState = UpdateStateError(message: "Failed to update document")
}
}
} catch {
@@ -203,8 +209,10 @@ class DocumentViewModelWrapper: ObservableObject {
await MainActor.run {
if result is ApiResultSuccess<KotlinUnit> {
self.deleteState = DeleteStateSuccess()
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.deleteState = DeleteStateError(message: error.message)
} else {
self.deleteState = DeleteStateError(message: "Failed to delete document")
}
}
} catch {
@@ -239,8 +247,10 @@ class DocumentViewModelWrapper: ObservableObject {
await MainActor.run {
if result is ApiResultSuccess<KotlinUnit> {
self.deleteImageState = DeleteImageStateSuccess()
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.deleteImageState = DeleteImageStateError(message: error.message)
} else {
self.deleteImageState = DeleteImageStateError(message: "Failed to delete image")
}
}
} catch {

View File

@@ -17,6 +17,8 @@ struct DocumentsWarrantiesView: View {
@State private var showFilterMenu = false
@State private var showAddSheet = false
@State private var showingUpgradePrompt = false
@State private var pushTargetDocumentId: Int32?
@State private var navigateToPushDocument = false
let residenceId: Int32?
@@ -167,7 +169,7 @@ struct DocumentsWarrantiesView: View {
Button(action: {
let currentCount = documentViewModel.documents.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "documents") {
PostHogAnalytics.shared.capture(AnalyticsEvents.documentsPaywallShown, properties: ["current_count": currentCount])
AnalyticsManager.shared.track(.documentsPaywallShown(currentCount: currentCount))
showingUpgradePrompt = true
} else {
showAddSheet = true
@@ -179,7 +181,10 @@ struct DocumentsWarrantiesView: View {
}
}
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.documentsScreenShown)
AnalyticsManager.shared.trackScreen(.documents)
if let pendingDocumentId = PushNotificationManager.shared.pendingNavigationDocumentId {
navigateToDocumentFromPush(documentId: pendingDocumentId)
}
loadAllDocuments()
}
.sheet(isPresented: $showAddSheet) {
@@ -193,6 +198,28 @@ struct DocumentsWarrantiesView: View {
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "view_documents", isPresented: $showingUpgradePrompt)
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToDocument)) { notification in
if let documentId = notification.userInfo?["documentId"] as? Int {
navigateToDocumentFromPush(documentId: documentId)
} else {
selectedTab = .warranties
}
}
.background(
NavigationLink(
destination: Group {
if let documentId = pushTargetDocumentId {
DocumentDetailView(documentId: documentId)
} else {
EmptyView()
}
},
isActive: $navigateToPushDocument
) {
EmptyView()
}
.hidden()
)
}
private func loadAllDocuments(forceRefresh: Bool = false) {
@@ -206,6 +233,13 @@ struct DocumentsWarrantiesView: View {
private func loadDocuments() {
loadAllDocuments()
}
private func navigateToDocumentFromPush(documentId: Int) {
selectedTab = .warranties
pushTargetDocumentId = Int32(documentId)
navigateToPushDocument = true
PushNotificationManager.shared.pendingNavigationDocumentId = nil
}
}
// MARK: - Organic Segmented Control

View File

@@ -4,6 +4,8 @@ extension Notification.Name {
// Navigation notifications from push notification actions
static let navigateToTask = Notification.Name("navigateToTask")
static let navigateToEditTask = Notification.Name("navigateToEditTask")
static let navigateToResidence = Notification.Name("navigateToResidence")
static let navigateToDocument = Notification.Name("navigateToDocument")
static let navigateToHome = Notification.Name("navigateToHome")
// Task action completion notification (for UI refresh)

View File

@@ -71,7 +71,7 @@ final class WidgetActionProcessor {
WidgetDataManager.shared.clearPendingState(forTaskId: taskId)
// Refresh tasks to update UI
await refreshTasks()
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
print("WidgetActionProcessor: Failed to complete task \(taskId): \(error.message)")
// Remove action to avoid infinite retries
WidgetDataManager.shared.removeAction(action)

View File

@@ -8,6 +8,10 @@
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CASERA_IAP_ANNUAL_PRODUCT_ID</key>
<string>com.example.casera.pro.annual</string>
<key>CASERA_IAP_MONTHLY_PRODUCT_ID</key>
<string>com.example.casera.pro.monthly</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>

View File

@@ -17342,6 +17342,9 @@
"Generate New Code" : {
"comment" : "A button label that appears when a user wants to generate a new invitation code.",
"isCommentAutoGenerated" : true
},
"Help improve Casera by sharing anonymous usage data" : {
},
"Hour" : {
"comment" : "A picker for selecting an hour.",
@@ -17442,6 +17445,9 @@
"No active code" : {
"comment" : "A message indicating that a user does not have an active share code.",
"isCommentAutoGenerated" : true
},
"No personal data is collected. Analytics are fully anonymous." : {
},
"No properties yet" : {
@@ -17502,6 +17508,9 @@
"Primary" : {
"comment" : "A label indicating that a residence is the user's primary residence.",
"isCommentAutoGenerated" : true
},
"Privacy" : {
},
"Pro" : {
"comment" : "The title of the \"Pro\" plan in the feature comparison view.",
@@ -24897,6 +24906,9 @@
}
}
}
},
"Share Analytics" : {
},
"Share Code" : {
"comment" : "A label displayed above the share code section of the view.",

View File

@@ -95,10 +95,10 @@ extension AppleSignInManager: ASAuthorizationControllerDelegate {
self.error = AppleSignInError.notInteractive
completionHandler?(.failure(AppleSignInError.notInteractive))
return
case .unknown:
break // Fall through to generic error
@unknown default:
break
default:
self.error = AppleSignInError.authorizationFailed
completionHandler?(.failure(AppleSignInError.authorizationFailed))
return
}
}

View File

@@ -60,8 +60,11 @@ class AppleSignInViewModel: ObservableObject {
if let success = result as? ApiResultSuccess<AppleSignInResponse>, let response = success.data {
self.handleSuccess(response)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.handleBackendError(error)
} else {
self.isLoading = false
self.errorMessage = "Sign in failed. Please try again."
}
} catch {
self.isLoading = false
@@ -90,13 +93,7 @@ class AppleSignInViewModel: ObservableObject {
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
// 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]
)
AnalyticsManager.shared.track(.userSignedInApple(isNewUser: isNewUser))
print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)")

View File

@@ -9,6 +9,7 @@ struct LoginView: View {
@State private var showVerification = false
@State private var showPasswordReset = false
@State private var isPasswordVisible = false
@State private var activeResetToken: String?
@Binding var resetToken: String?
var onLoginSuccess: (() -> Void)?
@@ -279,7 +280,7 @@ struct LoginView: View {
RegisterView()
}
.sheet(isPresented: $showPasswordReset) {
PasswordResetFlow(resetToken: resetToken, onLoginSuccess: { isVerified in
PasswordResetFlow(resetToken: activeResetToken, onLoginSuccess: { isVerified in
// Update the shared authentication manager
AuthenticationManager.shared.login(verified: isVerified)
@@ -291,8 +292,15 @@ struct LoginView: View {
})
}
.onChange(of: resetToken) { _, token in
if token != nil {
if let token {
activeResetToken = token
showPasswordReset = true
resetToken = nil
}
}
.onChange(of: showPasswordReset) { _, isPresented in
if !isPresented {
activeResetToken = nil
}
}
}

View File

@@ -78,11 +78,7 @@ class LoginViewModel: ObservableObject {
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
// 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 ?? ""]
)
AnalyticsManager.shared.track(.userSignedIn(method: "email"))
// Initialize lookups via APILayer
Task {
@@ -91,9 +87,12 @@ class LoginViewModel: ObservableObject {
// Call login success callback
self.onLoginSuccess?(self.isVerified)
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.isLoading = false
self.handleLoginError(error)
} else {
self.isLoading = false
self.errorMessage = "Failed to sign in"
}
} catch {
self.isLoading = false
@@ -134,11 +133,14 @@ class LoginViewModel: ObservableObject {
// APILayer.logout clears DataManager
try? await APILayer.shared.logout()
// Reset PostHog user identity
PostHogAnalytics.shared.reset()
SubscriptionCacheWrapper.shared.clear()
PushNotificationManager.shared.clearRegistrationCache()
// Clear widget task data
WidgetDataManager.shared.clearCache()
WidgetDataManager.shared.clearAuthToken()
// Reset local state
self.isVerified = false

View File

@@ -81,6 +81,10 @@ struct MainTabView: View {
// Handle pending navigation from push notification
if pushManager.pendingNavigationTaskId != nil {
selectedTab = 1
} else if pushManager.pendingNavigationDocumentId != nil {
selectedTab = 3
} else if pushManager.pendingNavigationResidenceId != nil {
selectedTab = 0
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
@@ -89,6 +93,12 @@ struct MainTabView: View {
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
selectedTab = 1
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { _ in
selectedTab = 0
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToDocument)) { _ in
selectedTab = 3
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
selectedTab = 0
}

View File

@@ -5,7 +5,9 @@ import StoreKit
struct OnboardingSubscriptionContent: View {
var onSubscribe: () -> Void
@StateObject private var storeKit = StoreKitManager.shared
@State private var isLoading = false
@State private var purchaseError: String?
@State private var selectedPlan: PricingPlan = .yearly
@State private var animateBadge = false
@Environment(\.colorScheme) var colorScheme
@@ -222,6 +224,20 @@ struct OnboardingSubscriptionContent: View {
// CTA buttons
VStack(spacing: 14) {
if let purchaseError = purchaseError {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(purchaseError)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appError)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
Button(action: startFreeTrial) {
HStack(spacing: 10) {
if isLoading {
@@ -280,31 +296,52 @@ struct OnboardingSubscriptionContent: View {
.onAppear {
animateBadge = true
}
.task {
if storeKit.products.isEmpty {
await storeKit.loadProducts()
}
}
}
private func startFreeTrial() {
isLoading = true
purchaseError = nil
// Initiate StoreKit purchase flow
Task {
do {
// This would integrate with your StoreKitManager
// For now, we'll simulate the flow
try await Task.sleep(nanoseconds: 1_500_000_000)
if storeKit.products.isEmpty {
await storeKit.loadProducts()
}
guard let product = productForSelectedPlan() else {
throw StoreKitManager.StoreKitError.noProducts
}
let transaction = try await storeKit.purchase(product)
await MainActor.run {
isLoading = false
onSubscribe()
if transaction != nil {
onSubscribe()
} else {
purchaseError = "Purchase was cancelled. You can continue with Free or try again."
}
}
} catch {
await MainActor.run {
isLoading = false
// Handle error - still proceed (they can subscribe later)
onSubscribe()
purchaseError = "Unable to start trial: \(error.localizedDescription)"
}
}
}
}
private func productForSelectedPlan() -> Product? {
let productIdHint = selectedPlan == .yearly ? "annual" : "monthly"
return storeKit.products.first { $0.id.localizedCaseInsensitiveContains(productIdHint) }
?? storeKit.products.first
}
}
// MARK: - Pricing Plan Enum

View File

@@ -63,9 +63,12 @@ class PasswordResetViewModel: ObservableObject {
self.successMessage = nil
self.currentStep = .verifyCode
}
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.isLoading = false
self.errorMessage = ErrorMessageParser.parse(error.message)
} else {
self.isLoading = false
self.errorMessage = "Failed to request password reset"
}
} catch {
self.isLoading = false
@@ -100,9 +103,12 @@ class PasswordResetViewModel: ObservableObject {
self.successMessage = nil
self.currentStep = .resetPassword
}
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.isLoading = false
self.handleVerifyError(ErrorMessageParser.parse(error.message))
} else {
self.isLoading = false
self.errorMessage = "Failed to verify code"
}
} catch {
self.isLoading = false
@@ -149,9 +155,12 @@ class PasswordResetViewModel: ObservableObject {
self.successMessage = "Password reset successfully! Logging you in..."
self.currentStep = .loggingIn
await self.autoLogin()
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.isLoading = false
self.errorMessage = ErrorMessageParser.parse(error.message)
} else {
self.isLoading = false
self.errorMessage = "Failed to reset password"
}
} catch {
self.isLoading = false
@@ -189,12 +198,16 @@ class PasswordResetViewModel: ObservableObject {
// Call the login success callback
self.onLoginSuccess?(isVerified)
} else if let error = loginResult as? ApiResultError {
} else if let error = ApiResultBridge.error(from: loginResult) {
// Auto-login failed, fall back to manual login
print("Auto-login failed: \(error.message)")
self.isLoading = false
self.successMessage = "Password reset successfully! You can now log in with your new password."
self.currentStep = .success
} else {
self.isLoading = false
self.successMessage = "Password reset successfully! You can now log in with your new password."
self.currentStep = .success
}
} catch {
// Auto-login failed, fall back to manual login
@@ -260,11 +273,13 @@ class PasswordResetViewModel: ObservableObject {
private func handleVerifyError(_ message: String) {
// Handle specific error cases
if message.contains("expired") {
let normalized = message.lowercased()
if normalized.contains("expired") {
errorMessage = "Reset code has expired. Please request a new one."
} else if message.contains("attempts") {
} else if normalized.contains("attempts") {
errorMessage = "Too many failed attempts. Please request a new reset code."
} else if message.contains("Invalid") && message.contains("token") {
} else if normalized.contains("invalid") && normalized.contains("token") {
errorMessage = "Invalid or expired reset token. Please start over."
} else {
errorMessage = message

View File

@@ -320,7 +320,7 @@ struct NotificationPreferencesView: View {
}
.onAppear {
// Track screen view
PostHogAnalytics.shared.screen(AnalyticsEvents.notificationSettingsScreenShown)
AnalyticsManager.shared.trackScreen(.notificationSettings)
viewModel.loadPreferences()
}
}
@@ -399,9 +399,12 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
self.isLoading = false
self.errorMessage = nil
} 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 notification preferences"
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -452,9 +455,12 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
if result is ApiResultSuccess<NotificationPreference> {
self.isSaving = false
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isSaving = false
} else {
self.errorMessage = "Failed to save notification preferences"
self.isSaving = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)

View File

@@ -172,6 +172,39 @@ struct ProfileTabView: View {
}
.sectionBackground()
Section {
Toggle(isOn: Binding(
get: { !AnalyticsManager.shared.isOptedOut },
set: { enabled in
if enabled {
AnalyticsManager.shared.optIn()
} else {
AnalyticsManager.shared.optOut()
}
}
)) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Share Analytics")
.font(.body)
.foregroundStyle(Color.appTextPrimary)
Text("Help improve Casera by sharing anonymous usage data")
.font(.caption)
.foregroundStyle(Color.appTextSecondary)
}
} icon: {
Image(systemName: "chart.bar.xaxis")
.foregroundColor(Color.appPrimary)
}
}
.tint(Color.appPrimary)
} header: {
Text("Privacy")
} footer: {
Text("No personal data is collected. Analytics are fully anonymous.")
}
.sectionBackground()
Section(L10n.Profile.support) {
Button(action: {
sendSupportEmail()
@@ -245,7 +278,7 @@ struct ProfileTabView: View {
Text(L10n.Profile.purchasesRestoredMessage)
}
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.settingsScreenShown)
AnalyticsManager.shared.trackScreen(.settings)
}
}

View File

@@ -66,9 +66,12 @@ class ProfileViewModel: ObservableObject {
if result is ApiResultSuccess<User> {
self.isLoadingUser = false
self.errorMessage = nil
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoadingUser = false
} else {
self.errorMessage = "Failed to load profile"
self.isLoadingUser = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
@@ -106,10 +109,14 @@ class ProfileViewModel: ObservableObject {
self.isLoading = false
self.errorMessage = nil
self.successMessage = "Profile updated successfully"
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.isLoading = false
self.errorMessage = ErrorMessageParser.parse(error.message)
self.successMessage = nil
} else {
self.isLoading = false
self.errorMessage = "Failed to update profile"
self.successMessage = nil
}
} catch {
self.isLoading = false

View File

@@ -44,7 +44,7 @@ struct ThemeSelectionView: View {
generator.impactOccurred()
// Track theme change
PostHogAnalytics.shared.capture(AnalyticsEvents.themeChanged, properties: ["theme": theme.rawValue])
AnalyticsManager.shared.track(.themeChanged(theme: theme.rawValue))
// Update theme with animation
themeManager.setTheme(theme)

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -231,7 +231,7 @@ struct RegisterView: View {
)
}
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.registrationScreenShown)
AnalyticsManager.shared.trackScreen(.registration)
}
}
}

View File

@@ -60,18 +60,14 @@ class RegisterViewModel: ObservableObject {
WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl())
// 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 ?? ""]
)
AnalyticsManager.shared.track(.userRegistered(method: "email"))
// Update AuthenticationManager - user is authenticated but NOT verified
AuthenticationManager.shared.login(verified: false)
self.isRegistered = true
self.isLoading = false
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
// Handle specific HTTP status codes
if let code = error.code?.intValue {
switch code {
@@ -91,6 +87,9 @@ class RegisterViewModel: ObservableObject {
self.errorMessage = ErrorMessageParser.parse(error.message)
}
self.isLoading = false
} else {
self.errorMessage = "Failed to create account"
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)

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
}
}

View File

@@ -317,7 +317,7 @@ struct ResidenceFormView: View {
}
.onAppear {
if !isEditMode {
PostHogAnalytics.shared.screen(AnalyticsEvents.newResidenceScreenShown)
AnalyticsManager.shared.trackScreen(.newResidence)
}
loadResidenceTypes()
initializeForm()
@@ -443,9 +443,7 @@ struct ResidenceFormView: View {
} else {
viewModel.createResidence(request: request) { success in
if success {
PostHogAnalytics.shared.capture(AnalyticsEvents.residenceCreated, properties: [
"residence_type": selectedPropertyType?.name ?? "unknown"
])
AnalyticsManager.shared.track(.residenceCreated(type: selectedPropertyType?.name ?? "unknown"))
onSuccess?()
isPresented = false
}

View File

@@ -29,6 +29,11 @@ class AuthenticationManager: ObservableObject {
// Fetch current user and initialize lookups immediately for all authenticated users
Task { @MainActor in
do {
// Prefer cached user state when available to avoid blocking on transient failures.
if let cachedUser = DataManagerObservable.shared.currentUser {
self.isVerified = cachedUser.verified
}
// Initialize lookups right away for any authenticated user
// This fetches /static_data/ and /upgrade-triggers/ at app start
print("🚀 Initializing lookups at app start...")
@@ -47,18 +52,22 @@ class AuthenticationManager: ObservableObject {
if self.isVerified {
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
}
} else if result is ApiResultError {
// Token is invalid, clear all data via DataManager
DataManager.shared.clear()
self.isAuthenticated = false
self.isVerified = false
} else if let error = ApiResultBridge.error(from: result) {
if self.shouldForceLogout(for: error) {
DataManager.shared.clear()
SubscriptionCacheWrapper.shared.clear()
PushNotificationManager.shared.clearRegistrationCache()
self.isAuthenticated = false
self.isVerified = false
} else {
print("⚠️ Auth status check failed but session kept: \(error.message)")
}
} else {
print("⚠️ Auth status check returned unexpected result type: \(type(of: result))")
}
} catch {
print("❌ Failed to check auth status: \(error)")
// On error, assume token is invalid
DataManager.shared.clear()
self.isAuthenticated = false
self.isVerified = false
// Keep session on transient failures and preserve last-known verification state.
}
self.isCheckingAuth = false
@@ -90,6 +99,9 @@ class AuthenticationManager: ObservableObject {
_ = try? await APILayer.shared.logout()
}
SubscriptionCacheWrapper.shared.clear()
PushNotificationManager.shared.clearRegistrationCache()
// Clear widget data (tasks and auth token)
WidgetDataManager.shared.clearCache()
WidgetDataManager.shared.clearAuthToken()
@@ -108,15 +120,31 @@ class AuthenticationManager: ObservableObject {
func resetOnboarding() {
OnboardingState.shared.reset()
}
private func shouldForceLogout(for error: ApiResultError) -> Bool {
if let statusCode = error.code?.intValue, statusCode == 401 || statusCode == 403 {
return true
}
let message = error.message.lowercased()
return message.contains("error.invalid_token")
|| message.contains("error.not_authenticated")
|| message.contains("not authenticated")
}
}
/// Root view that handles authentication flow: loading -> onboarding -> login -> verify email -> main app
struct RootView: View {
@Binding private var resetToken: String?
@EnvironmentObject private var themeManager: ThemeManager
@StateObject private var authManager = AuthenticationManager.shared
@StateObject private var onboardingState = OnboardingState.shared
@State private var refreshID = UUID()
init(resetToken: Binding<String?> = .constant(nil)) {
self._resetToken = resetToken
}
var body: some View {
Group {
if authManager.isCheckingAuth {
@@ -132,7 +160,7 @@ struct RootView: View {
})
} else if !authManager.isAuthenticated {
// Show login screen for returning users
LoginView()
LoginView(resetToken: $resetToken)
} else if !authManager.isVerified {
// Show email verification screen (for returning users who haven't verified)
VerifyEmailView(

View File

@@ -7,12 +7,29 @@ import ComposeApp
class StoreKitManager: ObservableObject {
static let shared = StoreKitManager()
// Product IDs (must match App Store Connect and Configuration.storekit)
private let productIDs = [
// Product IDs can be configured via Info.plist keys:
// CASERA_IAP_MONTHLY_PRODUCT_ID / CASERA_IAP_ANNUAL_PRODUCT_ID.
// Falls back to local StoreKit config IDs for development.
private let fallbackProductIDs = [
"com.example.casera.pro.monthly",
"com.example.casera.pro.annual"
]
private var configuredProductIDs: [String] {
let monthly = Bundle.main.object(forInfoDictionaryKey: "CASERA_IAP_MONTHLY_PRODUCT_ID") as? String
let annual = Bundle.main.object(forInfoDictionaryKey: "CASERA_IAP_ANNUAL_PRODUCT_ID") as? String
return [monthly, annual]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private var productIDs: [String] {
if configuredProductIDs.isEmpty {
return fallbackProductIDs
}
return configuredProductIDs
}
@Published var products: [Product] = []
@Published var purchasedProductIDs: Set<String> = []
@Published var isLoading = false
@@ -41,6 +58,9 @@ class StoreKitManager: ObservableObject {
defer { isLoading = false }
do {
if configuredProductIDs.isEmpty {
print("⚠️ StoreKit: Using fallback product IDs (configure CASERA_IAP_MONTHLY_PRODUCT_ID/CASERA_IAP_ANNUAL_PRODUCT_ID in Info.plist for production)")
}
let loadedProducts = try await Product.products(for: productIDs)
products = loadedProducts.sorted { $0.price < $1.price }
print("✅ StoreKit: Loaded \(products.count) products")
@@ -159,7 +179,7 @@ class StoreKitManager: ObservableObject {
// Update local purchased products
await MainActor.run {
purchasedProductIDs.insert(transaction.productID)
_ = purchasedProductIDs.insert(transaction.productID)
}
}
}
@@ -260,7 +280,7 @@ class StoreKitManager: ObservableObject {
SubscriptionCacheWrapper.shared.updateSubscription(subscription)
}
}
} else if let errorResult = result as? ApiResultError {
} else if let errorResult = ApiResultBridge.error(from: result) {
print("❌ StoreKit: Backend verification failed: \(errorResult.message)")
} else if let successResult = result as? ApiResultSuccess<VerificationResponse>,
let response = successResult.data,

View File

@@ -10,9 +10,17 @@ class SubscriptionCacheWrapper: ObservableObject {
@Published var featureBenefits: [FeatureBenefit] = []
@Published var promotions: [Promotion] = []
/// Current tier based on StoreKit purchases
/// Current tier resolved from backend status when available, with StoreKit fallback.
var currentTier: String {
// Check if user has any active subscriptions via StoreKit
// Prefer backend subscription state when available.
// `expiresAt` is only expected for active paid plans.
if let subscription = currentSubscription,
let expiresAt = subscription.expiresAt,
!expiresAt.isEmpty {
return "pro"
}
// Fallback to local StoreKit entitlements.
return StoreKitManager.shared.purchasedProductIDs.isEmpty ? "free" : "pro"
}
@@ -42,13 +50,13 @@ class SubscriptionCacheWrapper: ObservableObject {
let limit: Int?
switch limitKey {
case "properties":
limit = tierLimits.properties != nil ? Int(truncating: tierLimits.properties!) : nil
limit = tierLimits.properties.map { Int(truncating: $0) }
case "tasks":
limit = tierLimits.tasks != nil ? Int(truncating: tierLimits.tasks!) : nil
limit = tierLimits.tasks.map { Int(truncating: $0) }
case "contractors":
limit = tierLimits.contractors != nil ? Int(truncating: tierLimits.contractors!) : nil
limit = tierLimits.contractors.map { Int(truncating: $0) }
case "documents":
limit = tierLimits.documents != nil ? Int(truncating: tierLimits.documents!) : nil
limit = tierLimits.documents.map { Int(truncating: $0) }
default:
print("⚠️ Unknown limit key: \(limitKey)")
return false
@@ -181,4 +189,3 @@ class SubscriptionCacheWrapper: ObservableObject {
}
}
}

View File

@@ -102,7 +102,7 @@ struct AllTasksView: View {
}
}
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown)
AnalyticsManager.shared.trackScreen(.tasks)
if let taskId = PushNotificationManager.shared.pendingNavigationTaskId {
pendingTaskId = Int32(taskId)

View File

@@ -221,7 +221,11 @@ struct CompleteTaskView: View {
onRemove: {
withAnimation {
selectedImages.remove(at: index)
selectedItems.remove(at: index)
// Camera photos don't exist in selectedItems.
// Guard the index to avoid out-of-bounds crashes.
if index < selectedItems.count {
selectedItems.remove(at: index)
}
}
}
)
@@ -333,25 +337,19 @@ struct CompleteTaskView: View {
Task {
for await state in completionViewModel.createCompletionState {
await MainActor.run {
switch state {
case let success as ApiResultSuccess<TaskCompletionResponse>:
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
self.isSubmitting = false
self.onComplete(success.data?.updatedTask) // Pass back updated task
self.dismiss()
case let error as ApiResultError:
} else if let error = ApiResultBridge.error(from: state) {
self.errorMessage = error.message
self.showError = true
self.isSubmitting = false
case is ApiResultLoading:
// Still loading, continue waiting
break
default:
break
}
}
// Break out of loop on terminal states
if state is ApiResultSuccess<TaskCompletionResponse> || state is ApiResultError {
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
break
}
}
@@ -470,4 +468,3 @@ struct ContractorPickerView: View {
}
}
}

View File

@@ -311,7 +311,7 @@ struct TaskFormView: View {
.onAppear {
// Track screen view for new tasks
if !isEditMode {
PostHogAnalytics.shared.screen(AnalyticsEvents.newTaskScreenShown)
AnalyticsManager.shared.trackScreen(.newTask)
}
// Set defaults when lookups are available
if dataManager.lookupsInitialized {
@@ -521,7 +521,7 @@ struct TaskFormView: View {
viewModel.createTask(request: request) { success in
if success {
// Track task creation
PostHogAnalytics.shared.capture(AnalyticsEvents.taskCreated, properties: ["residence_id": actualResidenceId])
AnalyticsManager.shared.track(.taskCreated(residenceId: actualResidenceId))
// View will dismiss automatically via onChange
}
}

View File

@@ -75,7 +75,7 @@ class TaskViewModel: ObservableObject {
do {
let result = try await APILayer.shared.createTask(request: request)
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
self.actionState = .error(.create, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
@@ -101,7 +101,7 @@ class TaskViewModel: ObservableObject {
// Check for error first, then treat non-error as success
// This handles Kotlin-Swift generic type bridging issues
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
self.actionState = .error(.cancel, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
@@ -126,7 +126,7 @@ class TaskViewModel: ObservableObject {
do {
let result = try await APILayer.shared.uncancelTask(taskId: id)
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
self.actionState = .error(.uncancel, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
@@ -150,7 +150,7 @@ class TaskViewModel: ObservableObject {
do {
let result = try await APILayer.shared.markInProgress(taskId: id)
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
self.actionState = .error(.markInProgress, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
@@ -174,7 +174,7 @@ class TaskViewModel: ObservableObject {
do {
let result = try await APILayer.shared.archiveTask(taskId: id)
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
self.actionState = .error(.archive, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
@@ -198,7 +198,7 @@ class TaskViewModel: ObservableObject {
do {
let result = try await APILayer.shared.unarchiveTask(taskId: id)
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
self.actionState = .error(.unarchive, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
@@ -222,7 +222,7 @@ class TaskViewModel: ObservableObject {
do {
let result = try await APILayer.shared.updateTask(id: id, request: request)
if let error = result as? ApiResultError {
if let error = ApiResultBridge.error(from: result) {
self.actionState = .error(.update, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
@@ -263,7 +263,7 @@ class TaskViewModel: ObservableObject {
if let success = result as? ApiResultSuccess<NSArray> {
self.completions = (success.data as? [TaskCompletionResponse]) ?? []
self.isLoadingCompletions = false
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.completionsError = ErrorMessageParser.parse(error.message)
self.isLoadingCompletions = false
}
@@ -336,7 +336,7 @@ class TaskViewModel: ObservableObject {
// tasksResponse is updated via DataManagerObservable observation
// Ensure loading state is cleared on success
self.isLoadingTasks = false
} else if let error = result as? ApiResultError {
} else if let error = ApiResultBridge.error(from: result) {
self.tasksError = error.message
self.isLoadingTasks = false
} else {

View File

@@ -52,9 +52,12 @@ class VerifyEmailViewModel: ObservableObject {
self.errorMessage = "Verification failed"
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 verify email"
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)

View File

@@ -33,7 +33,7 @@ struct iOSApp: App {
TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance())
// Initialize PostHog Analytics
PostHogAnalytics.shared.initialize()
AnalyticsManager.shared.configure()
// Initialize lookups at app start (public endpoints, no auth required)
// This fetches /static_data/ and /upgrade-triggers/ immediately
@@ -46,7 +46,7 @@ struct iOSApp: App {
var body: some Scene {
WindowGroup {
RootView()
RootView(resetToken: $deepLinkResetToken)
.environmentObject(themeManager)
.environmentObject(contractorSharingManager)
.environmentObject(residenceSharingManager)
@@ -55,6 +55,9 @@ struct iOSApp: App {
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
// Refresh analytics super properties (subscription, settings may have changed)
AnalyticsManager.shared.updateSuperProperties()
// Sync auth token to widget if user is logged in
// This ensures widget has credentials even if user logged in before widget support was added
if let token = TokenStorage.shared.getToken() {
@@ -90,6 +93,9 @@ struct iOSApp: App {
}
}
} else if newPhase == .background {
// Flush pending analytics events before app suspends
AnalyticsManager.shared.flush()
// Refresh widget when app goes to background
WidgetCenter.shared.reloadAllTimelines()
@@ -184,7 +190,7 @@ struct iOSApp: App {
/// Handles all incoming URLs - both deep links and file opens
private func handleIncomingURL(url: URL) {
print("URL received: \(url)")
print("URL received with scheme: \(url.scheme ?? "unknown")")
// Handle .casera file imports
if url.pathExtension.lowercased() == "casera" {
@@ -198,7 +204,7 @@ struct iOSApp: App {
return
}
print("Unrecognized URL: \(url)")
print("Unrecognized URL scheme: \(url.scheme ?? "unknown")")
}
/// Handles .casera file imports - detects type and routes accordingly
@@ -255,7 +261,7 @@ struct iOSApp: App {
if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let queryItems = components.queryItems,
let token = queryItems.first(where: { $0.name == "token" })?.value {
print("Reset token extracted: \(token)")
print("Password reset deep link received")
deepLinkResetToken = token
} else {
print("No token found in deep link")