From 2fc4a48fc9e02f0cecaea3e9fb94395ce32bed96 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 11 Feb 2026 09:48:49 -0600 Subject: [PATCH] Replace PostHog integration with AnalyticsManager architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- iosApp/PostHog-iOS-Integration-Guide.md | 1054 +++++++++++++++++ iosApp/iosApp/Analytics/AnalyticsEvent.swift | 92 ++ .../iosApp/Analytics/AnalyticsManager.swift | 286 +++++ .../iosApp/Analytics/PostHogAnalytics.swift | 116 -- .../Background/BackgroundTaskManager.swift | 5 +- .../Contractor/ContractorFormSheet.swift | 4 +- .../Contractor/ContractorSharingManager.swift | 4 +- .../Contractor/ContractorViewModel.swift | 33 +- .../Contractor/ContractorsListView.swift | 4 +- iosApp/iosApp/Core/ApiResultBridge.swift | 13 + iosApp/iosApp/Core/StateFlowObserver.swift | 4 +- .../iosApp/Data/DataManagerObservable.swift | 66 +- .../iosApp/Documents/DocumentFormView.swift | 5 +- .../iosApp/Documents/DocumentViewModel.swift | 95 +- .../Documents/DocumentViewModelWrapper.swift | 28 +- .../Documents/DocumentsWarrantiesView.swift | 38 +- .../Extensions/Notification+Names.swift | 2 + .../Helpers/WidgetActionProcessor.swift | 2 +- iosApp/iosApp/Info.plist | 4 + iosApp/iosApp/Localizable.xcstrings | 12 + iosApp/iosApp/Login/AppleSignInManager.swift | 8 +- .../iosApp/Login/AppleSignInViewModel.swift | 13 +- iosApp/iosApp/Login/LoginView.swift | 12 +- iosApp/iosApp/Login/LoginViewModel.swift | 18 +- iosApp/iosApp/MainTabView.swift | 10 + .../OnboardingSubscriptionView.swift | 49 +- .../PasswordResetViewModel.swift | 29 +- .../Profile/NotificationPreferencesView.swift | 12 +- iosApp/iosApp/Profile/ProfileTabView.swift | 35 +- iosApp/iosApp/Profile/ProfileViewModel.swift | 11 +- .../iosApp/Profile/ThemeSelectionView.swift | 2 +- .../PushNotifications/AppDelegate.swift | 13 +- .../PushNotificationManager.swift | 186 ++- iosApp/iosApp/Register/RegisterView.swift | 2 +- .../iosApp/Register/RegisterViewModel.swift | 11 +- iosApp/iosApp/Residence/ManageUsersView.swift | 6 +- .../Residence/ResidenceDetailView.swift | 8 +- .../Residence/ResidenceSharingManager.swift | 6 +- .../iosApp/Residence/ResidenceViewModel.swift | 28 +- .../iosApp/Residence/ResidencesListView.swift | 33 +- iosApp/iosApp/ResidenceFormView.swift | 6 +- iosApp/iosApp/RootView.swift | 48 +- .../iosApp/Subscription/StoreKitManager.swift | 28 +- .../Subscription/SubscriptionCache.swift | 21 +- iosApp/iosApp/Task/AllTasksView.swift | 2 +- iosApp/iosApp/Task/CompleteTaskView.swift | 19 +- iosApp/iosApp/Task/TaskFormView.swift | 4 +- iosApp/iosApp/Task/TaskViewModel.swift | 18 +- .../VerifyEmail/VerifyEmailViewModel.swift | 5 +- iosApp/iosApp/iOSApp.swift | 16 +- 50 files changed, 2191 insertions(+), 335 deletions(-) create mode 100644 iosApp/PostHog-iOS-Integration-Guide.md create mode 100644 iosApp/iosApp/Analytics/AnalyticsEvent.swift create mode 100644 iosApp/iosApp/Analytics/AnalyticsManager.swift delete mode 100644 iosApp/iosApp/Analytics/PostHogAnalytics.swift create mode 100644 iosApp/iosApp/Core/ApiResultBridge.swift diff --git a/iosApp/PostHog-iOS-Integration-Guide.md b/iosApp/PostHog-iOS-Integration-Guide.md new file mode 100644 index 0000000..3aed45b --- /dev/null +++ b/iosApp/PostHog-iOS-Integration-Guide.md @@ -0,0 +1,1054 @@ +# PostHog iOS Integration Guide + +A drop-in reference for integrating PostHog analytics into any iOS/SwiftUI app. Derived from production implementations across multiple shipped apps. + +--- + +## Table of Contents + +1. [Installation](#1-installation) +2. [Architecture Overview](#2-architecture-overview) +3. [AnalyticsManager Setup](#3-analyticsmanager-setup) +4. [Event Definitions](#4-event-definitions) +5. [Screen Tracking](#5-screen-tracking) +6. [App Lifecycle Integration](#6-app-lifecycle-integration) +7. [Super Properties](#7-super-properties) +8. [Subscription Funnel Tracking](#8-subscription-funnel-tracking) +9. [User Identification & Person Properties](#9-user-identification--person-properties) +10. [Session Recording](#10-session-recording) +11. [Privacy & Opt-Out](#11-privacy--opt-out) +12. [Feature Flags](#12-feature-flags) +13. [Debug Configuration](#13-debug-configuration) +14. [Event Naming Conventions](#14-event-naming-conventions) +15. [Checklist](#15-checklist) + +--- + +## 1. Installation + +### Swift Package Manager (SPM) + +In Xcode: **File > Add Package Dependencies** + +``` +https://github.com/PostHog/posthog-ios.git +``` + +- Use **Up to Next Major Version** (e.g., `3.41.0`) +- Add `PostHog` framework to your **iOS app target only** (not widgets, watch, or extension targets) + +### Verify Installation + +After adding, confirm the dependency appears in your `Package.resolved`: + +```json +{ + "identity": "posthog-ios", + "kind": "remoteSourceControl", + "location": "https://github.com/PostHog/posthog-ios.git", + "state": { + "version": "3.41.0" + } +} +``` + +### Import + +```swift +import PostHog +``` + +--- + +## 2. Architecture Overview + +The integration follows a three-file architecture: + +``` +YourApp/ + Core/ + Analytics/ + AnalyticsManager.swift // Singleton wrapper around PostHog SDK + AnalyticsEvent.swift // Type-safe event enum +``` + +**Design principles:** + +- **Single abstraction layer** - All PostHog calls go through `AnalyticsManager`. No direct `PostHogSDK` calls scattered through the codebase. +- **Type-safe events** - A Swift enum prevents string typos, provides autocomplete, and documents all tracked events in one place. +- **@MainActor singleton** - Thread-safe by design. +- **Privacy-first** - Opt-out support, text masking, anonymous by default. + +--- + +## 3. AnalyticsManager Setup + +Create `AnalyticsManager.swift`: + +```swift +import Foundation +import PostHog +import UIKit + +@MainActor +final class AnalyticsManager { + + // MARK: - Singleton + + static let shared = AnalyticsManager() + private init() {} + + // MARK: - Configuration + + // Replace with your PostHog project API key + private static let apiKey = "phc_YOUR_API_KEY" + + // PostHog Cloud: "https://us.i.posthog.com" or "https://eu.i.posthog.com" + // Self-hosted: "https://your-posthog-instance.com" + private static let host = "https://us.i.posthog.com" + + private static let optOutKey = "analyticsOptedOut" + private static let sessionReplayKey = "analytics_session_replay_enabled" + + private var isConfigured = false + + // MARK: - ISO8601 Formatter (for date properties) + + 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 + } + + // Debug configuration + #if DEBUG + config.debug = true + config.flushAt = 1 // Flush every event immediately in debug + #endif + + PostHogSDK.shared.setup(config) + isConfigured = true + + // Register initial super properties + updateSuperProperties() + } + + // MARK: - Event Tracking + + /// Track a type-safe analytics event + 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 + + /// Track a screen view using the Screen enum + 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 + + /// Force flush pending events (call on app background) + func flush() { + guard isConfigured else { return } + PostHogSDK.shared.flush() + } + + /// Reset device identity and cached properties + func reset() { + guard isConfigured else { return } + PostHogSDK.shared.reset() + } + + // MARK: - Super Properties + + /// Register properties attached to every subsequent event. + /// Call on configure() and on every app foreground. + 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 + + // Add your app-specific properties here + var props: [String: Any] = [ + "app_version": version, + "build_number": build, + "device_model": device, + "os_version": osVersion, + "is_pro": false, // Replace with your subscription check + "animations_enabled": !UIAccessibility.isReduceMotionEnabled, + ] + + // Example: add user preferences from UserDefaults + // props["theme"] = UserDefaults.standard.string(forKey: "theme") ?? "default" + + 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) + } + + /// Set person-level subscription properties (persist across sessions) + 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 // Enabled by default + } + return UserDefaults.standard.bool(forKey: Self.sessionReplayKey) + } + set { + UserDefaults.standard.set(newValue, forKey: Self.sessionReplayKey) + if newValue { + PostHogSDK.shared.startSessionRecording() + } else { + PostHogSDK.shared.stopSessionRecording() + } + } + } +} +``` + +--- + +## 4. Event Definitions + +Create `AnalyticsEvent.swift` as a **separate file**. This keeps event definitions discoverable, provides autocomplete, and prevents event name typos. + +```swift +import Foundation + +enum AnalyticsEvent { + + // MARK: - Onboarding + case onboardingCompleted + case onboardingSkipped + + // MARK: - Navigation + case tabSwitched(tab: String, previousTab: String?) + + // MARK: - Core Feature (replace with your domain events) + // case itemCreated(itemId: String, type: String) + // case itemDeleted(itemId: String) + // case itemUpdated(itemId: String, field: String) + + // MARK: - Settings + case themeChanged(from: String, to: String) + case animationsToggled(enabled: Bool) + case settingsReset + case analyticsToggled(enabled: Bool) + + // MARK: - IAP (tracked via AnalyticsManager methods, not this enum) + // paywall_viewed, purchase_started, purchase_completed, etc. + + // MARK: - Errors + case errorOccurred(domain: String, message: String, screen: String?) + + // MARK: - Payload + + /// Returns (eventName, properties) tuple for PostHog capture + var payload: (String, [String: Any]?) { + switch self { + + // Onboarding + case .onboardingCompleted: + return ("onboarding_completed", nil) + case .onboardingSkipped: + return ("onboarding_skipped", nil) + + // Navigation + case .tabSwitched(let tab, let previousTab): + var props: [String: Any] = ["tab": tab] + if let previousTab { props["previous_tab"] = previousTab } + return ("tab_switched", props) + + // Settings + case .themeChanged(let from, let to): + return ("theme_changed", ["from": from, "to": to]) + case .animationsToggled(let enabled): + return ("animations_toggled", ["enabled": enabled]) + case .settingsReset: + return ("settings_reset", nil) + 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) + } + } +} +``` + +### Usage + +```swift +// Type-safe, autocomplete-friendly +AnalyticsManager.shared.track(.tabSwitched(tab: "home", previousTab: "settings")) +AnalyticsManager.shared.track(.themeChanged(from: "light", to: "dark")) +AnalyticsManager.shared.track(.errorOccurred(domain: "network", message: "timeout", screen: "home")) +``` + +### Adding New Events + +1. Add a case to `AnalyticsEvent` +2. Add the `payload` mapping in the switch +3. Call `AnalyticsManager.shared.track(.yourEvent(...))` + +That's it. No strings to get wrong. + +--- + +## 5. Screen Tracking + +### Screen Enum + +Add to `AnalyticsManager.swift` (or a separate file): + +```swift +extension AnalyticsManager { + enum Screen: String { + case home = "home" + case settings = "settings" + case onboarding = "onboarding" + case paywall = "paywall" + case detail = "detail" + // Add your screens here + } +} +``` + +### SwiftUI ViewModifier + +Add to `AnalyticsManager.swift`: + +```swift +// 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 { + /// Track a screen view when this view appears + func trackScreen(_ screen: AnalyticsManager.Screen, properties: [String: Any]? = nil) -> some View { + modifier(ScreenTrackingModifier(screen: screen, properties: properties)) + } +} +``` + +### Usage + +```swift +struct SettingsView: View { + var body: some View { + List { + // ... + } + .trackScreen(.settings) + } +} + +// With extra properties +struct PaywallView: View { + let source: String + + var body: some View { + VStack { + // ... + } + .trackScreen(.paywall, properties: ["source": source]) + } +} +``` + +### Note on Auto-Capture + +`config.captureScreenViews = true` auto-captures SwiftUI view transitions as `$screen` events. The manual `.trackScreen()` modifier gives you **named, queryable** screen events (`screen_viewed` with `screen_name`). Use both - auto-capture catches everything, manual tracking gives you clean data. + +--- + +## 6. App Lifecycle Integration + +Wire analytics into your `@main` App struct: + +```swift +@main +struct YourApp: App { + @Environment(\.scenePhase) private var scenePhase + + init() { + // Configure analytics early in app launch + AnalyticsManager.shared.configure() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .active: + // Refresh super properties (subscription status, settings may have changed) + AnalyticsManager.shared.updateSuperProperties() + + case .background: + // Flush pending events before app suspends + AnalyticsManager.shared.flush() + + case .inactive: + break + + @unknown default: + break + } + } + } +} +``` + +**Why this matters:** + +| Lifecycle Event | Action | Reason | +|----------------|--------|--------| +| `init()` | `configure()` | Analytics ready before first view appears | +| `.active` | `updateSuperProperties()` | Subscription status or settings may have changed while backgrounded | +| `.background` | `flush()` | Ensure pending events are sent before the OS suspends the app | + +--- + +## 7. Super Properties + +Super properties are key-value pairs attached to **every** event automatically. Use them for segmentation dimensions you always want available. + +### What to Include + +| Property | Source | Why | +|----------|--------|-----| +| `app_version` | `Bundle.main` | Correlate issues to releases | +| `build_number` | `Bundle.main` | Distinguish TestFlight builds | +| `device_model` | `UIDevice.current.model` | Device-specific issues | +| `os_version` | `UIDevice.current.systemVersion` | OS-specific behavior | +| `is_pro` | Your IAP manager | Segment free vs paid users | +| `animations_enabled` | `UIAccessibility.isReduceMotionEnabled` | Accessibility insights | +| App-specific settings | `UserDefaults` | Understand user preferences | + +### Registration Pattern + +```swift +// Called in configure() and on every foreground +func updateSuperProperties() { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" + + PostHogSDK.shared.register([ + "app_version": version, + "build_number": build, + "device_model": UIDevice.current.model, + "os_version": UIDevice.current.systemVersion, + "is_pro": YourIAPManager.shared.isSubscribed, + "animations_enabled": !UIAccessibility.isReduceMotionEnabled, + // Add your app-specific properties: + // "selected_theme": UserDefaults.standard.string(forKey: "theme") ?? "default", + ]) +} +``` + +### Rules + +- Only include **low-cardinality** values (booleans, enums, short strings). Don't register user IDs or timestamps. +- Call `updateSuperProperties()` on every foreground since values can change while backgrounded. +- Use `PostHogSDK.shared.register()` (not `registerOnce`). You want fresh values every time. + +--- + +## 8. Subscription Funnel Tracking + +Track the full purchase funnel as discrete events. This gives you conversion rates at each step. + +### Funnel Events + +``` +paywall_viewed → purchase_started → purchase_completed + → purchase_failed + → (user cancelled) +purchase_restored (separate flow) +``` + +### Implementation Pattern + +In your paywall/store view: + +```swift +// 1. When paywall appears +.onAppear { + AnalyticsManager.shared.trackPaywallViewed(source: "settings") +} + +// 2. When user taps subscribe +func subscribe(product: Product) async { + AnalyticsManager.shared.trackPurchaseStarted(productId: product.id, source: "paywall") + + do { + let result = try await product.purchase() + switch result { + case .success(let verification): + // Verify transaction... + AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: "paywall") + case .userCancelled: + AnalyticsManager.shared.trackPurchaseFailed( + productId: product.id, source: "paywall", error: "user_cancelled" + ) + case .pending: + AnalyticsManager.shared.trackPurchaseFailed( + productId: product.id, source: "paywall", error: "pending" + ) + @unknown default: + break + } + } catch { + AnalyticsManager.shared.trackPurchaseFailed( + productId: product.id, source: "paywall", error: error.localizedDescription + ) + } +} + +// 3. Restore purchases +func restore() async { + // ... restore logic + AnalyticsManager.shared.trackPurchaseRestored(source: "settings") +} +``` + +### Subscription Status Tracking + +Track detailed subscription state on every app foreground and after purchases. This lets you build subscription dashboards. + +```swift +// In your IAP/Store manager, call on foreground and after purchase events: +func trackSubscriptionAnalytics(source: String) { + AnalyticsManager.shared.trackSubscriptionStatusObserved( + status: "subscribed", // "subscribed", "free", "expired", "billing_retry", "grace_period", "revoked" + type: "monthly", // "monthly", "yearly", "none" + source: source, // "app_foreground", "purchase_success", "restore" + isSubscribed: true, + hasFullAccess: true, + productId: "com.app.monthly", + willAutoRenew: true, + isInGracePeriod: false, + trialDaysRemaining: nil, + expirationDate: Date().addingTimeInterval(30 * 24 * 60 * 60) + ) +} +``` + +**Source values to use consistently:** + +| Source | When | +|--------|------| +| `"app_foreground"` | Status checked on foreground | +| `"purchase_success"` | Just completed a purchase | +| `"restore"` | User restored purchases | +| `"entitlements_refresh"` | Background entitlement check | + +--- + +## 9. User Identification & Person Properties + +### Anonymous by Default + +PostHog assigns a random `distinctId` per device. For apps without user accounts, this is sufficient. **Do not call `identify()` unless you have authenticated users.** + +### If You Have User Accounts + +```swift +// After login - link anonymous events to this user +PostHogSDK.shared.identify(userId, userProperties: [ + "email": email, + "name": name, + "created_at": signUpDate.ISO8601Format(), +]) + +// After logout - generate new anonymous ID +AnalyticsManager.shared.reset() +``` + +### Person Properties (Without Accounts) + +Use `$set` to attach persistent properties to the anonymous user profile: + +```swift +PostHogSDK.shared.capture("$set", properties: [ + "$set": [ + "subscription_status": "subscribed", + "subscription_type": "yearly", + ], +]) +``` + +These persist across sessions and are visible in PostHog's person profiles. + +--- + +## 10. Session Recording + +### Configuration + +Session replay is configured during `configure()`: + +```swift +config.sessionReplay = sessionReplayEnabled // User-controllable +config.sessionReplayConfig.maskAllTextInputs = true // Privacy: mask passwords, emails, etc. +config.sessionReplayConfig.maskAllImages = false // Show images for context +config.sessionReplayConfig.captureNetworkTelemetry = true // Debug network issues +config.sessionReplayConfig.screenshotMode = true // REQUIRED for SwiftUI +``` + +### Key Settings + +| Setting | Recommended | Why | +|---------|-------------|-----| +| `maskAllTextInputs` | `true` | Prevents capturing passwords, emails, sensitive text | +| `maskAllImages` | `false` | Images provide useful visual context without PII risk | +| `captureNetworkTelemetry` | `true` | Correlate UI issues with network failures | +| `screenshotMode` | `true` | **Required for SwiftUI.** Without this, session replay won't work. | + +### Runtime Control + +Let users toggle session recording: + +```swift +// Start recording +PostHogSDK.shared.startSessionRecording() + +// Stop recording +PostHogSDK.shared.stopSessionRecording() +``` + +The `AnalyticsManager.sessionReplayEnabled` property handles this with UserDefaults persistence. + +--- + +## 11. Privacy & Opt-Out + +### Settings UI + +Add a toggle in your Settings view: + +```swift +private var analyticsToggle: some View { + 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(.primary) + Text("Help improve \(appName) by sharing anonymous usage data") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "chart.bar.xaxis") + } + } + } header: { + Text("Privacy") + } footer: { + Text("No personal data is collected. Analytics are fully anonymous.") + } +} +``` + +### Opt-Out Behavior + +The opt-out flow is intentionally ordered: + +```swift +func optOut() { + // 1. Capture the toggle event BEFORE opting out (so we know they left) + if isConfigured { + PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": false]) + } + // 2. Persist preference + UserDefaults.standard.set(true, forKey: Self.optOutKey) + // 3. Tell SDK to stop + if isConfigured { + PostHogSDK.shared.optOut() + } +} + +func optIn() { + // 1. Persist preference + UserDefaults.standard.set(false, forKey: Self.optOutKey) + // 2. Tell SDK to resume + if isConfigured { + PostHogSDK.shared.optIn() + // 3. Capture the toggle event AFTER opting in (so we know they returned) + PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": true]) + } +} +``` + +### Privacy Checklist + +- [ ] Analytics opt-out toggle in Settings +- [ ] Text inputs masked in session replay +- [ ] No PII in event properties (no emails, names, phone numbers) +- [ ] No PII in super properties +- [ ] Privacy policy link in Settings +- [ ] EULA link in Settings +- [ ] Opt-out preference respected on app restart (checked in `configure()`) + +--- + +## 12. Feature Flags + +PostHog supports feature flags for gradual rollouts and A/B testing. While the reference implementations don't use them yet, here's how to add them: + +### Basic Usage + +```swift +// Check a boolean flag +if PostHogSDK.shared.isFeatureEnabled("new-onboarding") { + showNewOnboarding() +} else { + showLegacyOnboarding() +} + +// Get a flag value (for multivariate flags) +let variant = PostHogSDK.shared.getFeatureFlag("checkout-button-color") as? String +``` + +### With Loading State + +Feature flags are fetched from the server. They may not be available immediately on cold start. + +```swift +// Reload flags explicitly +PostHogSDK.shared.reloadFeatureFlags { + let enabled = PostHogSDK.shared.isFeatureEnabled("new-feature") + // Update UI +} +``` + +### Tracking Experiments + +PostHog auto-tracks `$feature_flag_called` when you check a flag. For explicit experiment tracking: + +```swift +PostHogSDK.shared.capture("$feature_flag_called", properties: [ + "$feature_flag": "experiment-name", + "$feature_flag_response": "variant-a", +]) +``` + +--- + +## 13. Debug Configuration + +### DEBUG Build Settings + +```swift +#if DEBUG +config.debug = true // Prints all events to Xcode console +config.flushAt = 1 // Sends every event immediately (no batching) +#endif +``` + +### Console Output + +With `debug = true`, you'll see PostHog SDK logs in the Xcode console: + +``` +[PostHog] Captured event: tab_switched {tab: "settings", previous_tab: "home"} +``` + +The custom `#if DEBUG` print statements in the `track()` method add your own formatted output: + +``` +[Analytics] tab_switched ["tab": "settings", "previous_tab": "home"] +``` + +### Production Behavior + +In release builds: +- `debug` defaults to `false` (no console logging) +- `flushAt` uses the SDK default (batched, typically 20 events or 30 seconds) +- Events are queued and sent efficiently + +### Tip: Separate PostHog Projects + +For production apps, use separate PostHog projects for development and production: + +```swift +#if DEBUG +private static let apiKey = "phc_DEV_KEY" +#else +private static let apiKey = "phc_PROD_KEY" +#endif +``` + +This keeps test data out of your production dashboards. + +--- + +## 14. Event Naming Conventions + +Follow these conventions for consistency across all your apps. + +### Event Names + +| Rule | Example | Bad Example | +|------|---------|-------------| +| snake_case | `trip_planned` | `tripPlanned`, `Trip Planned` | +| past tense for completed actions | `mood_logged` | `log_mood` | +| `_tapped` for UI interactions | `subscribe_tapped` | `click_subscribe` | +| `_viewed` for screen/content views | `paywall_viewed` | `show_paywall` | +| `_toggled` / `_changed` for settings | `theme_changed` | `set_theme` | +| `_enabled` / `_disabled` for booleans | `notification_enabled` | `toggle_notification` | +| `_started` / `_completed` / `_failed` for flows | `purchase_started` | `begin_purchase` | + +### Property Names + +| Rule | Example | +|------|---------| +| snake_case | `product_id`, `screen_name` | +| Boolean properties describe the state | `enabled: true`, `is_pro: false` | +| IDs end with `_id` | `trip_id`, `stadium_id` | +| Counts end with `_count` | `stop_count`, `character_count` | +| Use `source` for origin tracking | `source: "settings"`, `source: "paywall"` | +| Dates use ISO8601 strings | `expiration_date: "2025-01-15T00:00:00Z"` | + +### Event Categories + +Group events by domain: + +```swift +enum AnalyticsEvent { + // MARK: - Onboarding + // MARK: - Navigation + // MARK: - Core Feature + // MARK: - Settings + // MARK: - Errors +} +``` + +--- + +## 15. Checklist + +### Initial Setup + +- [ ] Add `posthog-ios` via SPM +- [ ] Create `AnalyticsManager.swift` with your API key and host +- [ ] Create `AnalyticsEvent.swift` with initial events +- [ ] Add `Screen` enum with your app's screens +- [ ] Add `ScreenTrackingModifier` SwiftUI extension +- [ ] Call `AnalyticsManager.shared.configure()` in `App.init()` +- [ ] Add `flush()` on `.background` scene phase +- [ ] Add `updateSuperProperties()` on `.active` scene phase + +### Privacy + +- [ ] Analytics opt-out toggle in Settings +- [ ] Session replay text masking enabled (`maskAllTextInputs = true`) +- [ ] `screenshotMode = true` for SwiftUI +- [ ] Privacy policy and EULA links in Settings +- [ ] No PII in events or super properties +- [ ] Opt-out preference checked during `configure()` + +### Events + +- [ ] Define all events as `AnalyticsEvent` enum cases +- [ ] Follow snake_case naming convention +- [ ] Include `source` property where applicable +- [ ] Track error events with domain, message, and screen +- [ ] Add `.trackScreen()` modifier to all major views + +### Subscription Tracking (if applicable) + +- [ ] `paywall_viewed` with source +- [ ] `purchase_started` with product ID and source +- [ ] `purchase_completed` with product ID and source +- [ ] `purchase_failed` with product ID, source, and error +- [ ] `purchase_restored` with source +- [ ] `subscription_status_changed` on foreground and after purchase +- [ ] Person properties set for `subscription_status` and `subscription_type` + +### Super Properties + +- [ ] `app_version` and `build_number` +- [ ] `device_model` and `os_version` +- [ ] `is_pro` (subscription status) +- [ ] App-specific user preferences +- [ ] Updated on every foreground + +### Debug + +- [ ] `config.debug = true` in DEBUG builds +- [ ] `config.flushAt = 1` in DEBUG builds +- [ ] Console logging in `track()` method with `#if DEBUG` +- [ ] Consider separate API keys for dev/prod + +### Auto-Capture + +- [ ] `captureElementInteractions = true` +- [ ] `captureApplicationLifecycleEvents = true` +- [ ] `captureScreenViews = true` diff --git a/iosApp/iosApp/Analytics/AnalyticsEvent.swift b/iosApp/iosApp/Analytics/AnalyticsEvent.swift new file mode 100644 index 0000000..fe44e8b --- /dev/null +++ b/iosApp/iosApp/Analytics/AnalyticsEvent.swift @@ -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) + } + } +} diff --git a/iosApp/iosApp/Analytics/AnalyticsManager.swift b/iosApp/iosApp/Analytics/AnalyticsManager.swift new file mode 100644 index 0000000..1bab817 --- /dev/null +++ b/iosApp/iosApp/Analytics/AnalyticsManager.swift @@ -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)) + } +} diff --git a/iosApp/iosApp/Analytics/PostHogAnalytics.swift b/iosApp/iosApp/Analytics/PostHogAnalytics.swift deleted file mode 100644 index a76a1bd..0000000 --- a/iosApp/iosApp/Analytics/PostHogAnalytics.swift +++ /dev/null @@ -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" -} diff --git a/iosApp/iosApp/Background/BackgroundTaskManager.swift b/iosApp/iosApp/Background/BackgroundTaskManager.swift index c688f4f..941e043 100644 --- a/iosApp/iosApp/Background/BackgroundTaskManager.swift +++ b/iosApp/iosApp/Background/BackgroundTaskManager.swift @@ -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 diff --git a/iosApp/iosApp/Contractor/ContractorFormSheet.swift b/iosApp/iosApp/Contractor/ContractorFormSheet.swift index d81af6a..1903926 100644 --- a/iosApp/iosApp/Contractor/ContractorFormSheet.swift +++ b/iosApp/iosApp/Contractor/ContractorFormSheet.swift @@ -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() } diff --git a/iosApp/iosApp/Contractor/ContractorSharingManager.swift b/iosApp/iosApp/Contractor/ContractorSharingManager.swift index 087d7dd..1ee9fa9 100644 --- a/iosApp/iosApp/Contractor/ContractorSharingManager.swift +++ b/iosApp/iosApp/Contractor/ContractorSharingManager.swift @@ -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) diff --git a/iosApp/iosApp/Contractor/ContractorViewModel.swift b/iosApp/iosApp/Contractor/ContractorViewModel.swift index 9fd2c56..cb45953 100644 --- a/iosApp/iosApp/Contractor/ContractorViewModel.swift +++ b/iosApp/iosApp/Contractor/ContractorViewModel.swift @@ -52,9 +52,12 @@ class ContractorViewModel: ObservableObject { // API updates DataManager on success, which triggers our observation if result is ApiResultSuccess { 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 { 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 { // 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) diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index e04cd6d..2d0916b 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -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() } } diff --git a/iosApp/iosApp/Core/ApiResultBridge.swift b/iosApp/iosApp/Core/ApiResultBridge.swift new file mode 100644 index 0000000..dfad284 --- /dev/null +++ b/iosApp/iosApp/Core/ApiResultBridge.swift @@ -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 + } +} diff --git a/iosApp/iosApp/Core/StateFlowObserver.swift b/iosApp/iosApp/Core/StateFlowObserver.swift index 4724ee9..859d077 100644 --- a/iosApp/iosApp/Core/StateFlowObserver.swift +++ b/iosApp/iosApp/Core/StateFlowObserver.swift @@ -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?() diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index b8cb24c..e646c6c 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -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 to Swift [String: V] private func convertStringMap(_ 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 diff --git a/iosApp/iosApp/Documents/DocumentFormView.swift b/iosApp/iosApp/Documents/DocumentFormView.swift index a42c1b8..a63c039 100644 --- a/iosApp/iosApp/Documents/DocumentFormView.swift +++ b/iosApp/iosApp/Documents/DocumentFormView.swift @@ -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 diff --git a/iosApp/iosApp/Documents/DocumentViewModel.swift b/iosApp/iosApp/Documents/DocumentViewModel.swift index ed99a68..af3361f 100644 --- a/iosApp/iosApp/Documents/DocumentViewModel.swift +++ b/iosApp/iosApp/Documents/DocumentViewModel.swift @@ -56,9 +56,12 @@ class DocumentViewModel: ObservableObject { // API updates DataManager on success, which triggers our observation if result is ApiResultSuccess { 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 { + if let success = result as? ApiResultSuccess { + 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 { + 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) { + 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 + } } diff --git a/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift index 66f1e4d..f56ed58 100644 --- a/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift +++ b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift @@ -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 { 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, 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 { 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 { 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 { diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index 5191d6a..dfc49e1 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -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 diff --git a/iosApp/iosApp/Extensions/Notification+Names.swift b/iosApp/iosApp/Extensions/Notification+Names.swift index 871229d..0a70d5b 100644 --- a/iosApp/iosApp/Extensions/Notification+Names.swift +++ b/iosApp/iosApp/Extensions/Notification+Names.swift @@ -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) diff --git a/iosApp/iosApp/Helpers/WidgetActionProcessor.swift b/iosApp/iosApp/Helpers/WidgetActionProcessor.swift index eac9eec..a28c4c8 100644 --- a/iosApp/iosApp/Helpers/WidgetActionProcessor.swift +++ b/iosApp/iosApp/Helpers/WidgetActionProcessor.swift @@ -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) diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 14cf99e..c1c39ae 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -8,6 +8,10 @@ CADisableMinimumFrameDurationOnPhone + CASERA_IAP_ANNUAL_PRODUCT_ID + com.example.casera.pro.annual + CASERA_IAP_MONTHLY_PRODUCT_ID + com.example.casera.pro.monthly CFBundleDocumentTypes diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 44a7324..84eab9c 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -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.", diff --git a/iosApp/iosApp/Login/AppleSignInManager.swift b/iosApp/iosApp/Login/AppleSignInManager.swift index 1ab6092..b06028f 100644 --- a/iosApp/iosApp/Login/AppleSignInManager.swift +++ b/iosApp/iosApp/Login/AppleSignInManager.swift @@ -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 } } diff --git a/iosApp/iosApp/Login/AppleSignInViewModel.swift b/iosApp/iosApp/Login/AppleSignInViewModel.swift index 8e83564..ca4a193 100644 --- a/iosApp/iosApp/Login/AppleSignInViewModel.swift +++ b/iosApp/iosApp/Login/AppleSignInViewModel.swift @@ -60,8 +60,11 @@ class AppleSignInViewModel: ObservableObject { if let success = result as? ApiResultSuccess, 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)") diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index 0d527ca..ca3a50e 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -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 } } } diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift index 114932a..683fa3b 100644 --- a/iosApp/iosApp/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -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 diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 31e6ec5..f20a793 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -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 } diff --git a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift index 3229af5..79e06b5 100644 --- a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift @@ -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 diff --git a/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift index 9f43f12..02d921b 100644 --- a/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift +++ b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift @@ -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 diff --git a/iosApp/iosApp/Profile/NotificationPreferencesView.swift b/iosApp/iosApp/Profile/NotificationPreferencesView.swift index dc5120f..7752886 100644 --- a/iosApp/iosApp/Profile/NotificationPreferencesView.swift +++ b/iosApp/iosApp/Profile/NotificationPreferencesView.swift @@ -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 { 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) diff --git a/iosApp/iosApp/Profile/ProfileTabView.swift b/iosApp/iosApp/Profile/ProfileTabView.swift index f04914d..cd845ab 100644 --- a/iosApp/iosApp/Profile/ProfileTabView.swift +++ b/iosApp/iosApp/Profile/ProfileTabView.swift @@ -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) } } diff --git a/iosApp/iosApp/Profile/ProfileViewModel.swift b/iosApp/iosApp/Profile/ProfileViewModel.swift index 2bbf0c1..739b028 100644 --- a/iosApp/iosApp/Profile/ProfileViewModel.swift +++ b/iosApp/iosApp/Profile/ProfileViewModel.swift @@ -66,9 +66,12 @@ class ProfileViewModel: ObservableObject { if result is ApiResultSuccess { 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 diff --git a/iosApp/iosApp/Profile/ThemeSelectionView.swift b/iosApp/iosApp/Profile/ThemeSelectionView.swift index 8355ad9..73f96b1 100644 --- a/iosApp/iosApp/Profile/ThemeSelectionView.swift +++ b/iosApp/iosApp/Profile/ThemeSelectionView.swift @@ -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) diff --git a/iosApp/iosApp/PushNotifications/AppDelegate.swift b/iosApp/iosApp/PushNotifications/AppDelegate.swift index 54ac7b4..73d13de 100644 --- a/iosApp/iosApp/PushNotifications/AppDelegate.swift +++ b/iosApp/iosApp/PushNotifications/AppDelegate.swift @@ -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 diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift index 86698af..e63db28 100644 --- a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -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 { - 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 { 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 { 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 { 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) } } - diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift index 529a0bd..8d569ae 100644 --- a/iosApp/iosApp/Register/RegisterView.swift +++ b/iosApp/iosApp/Register/RegisterView.swift @@ -231,7 +231,7 @@ struct RegisterView: View { ) } .onAppear { - PostHogAnalytics.shared.screen(AnalyticsEvents.registrationScreenShown) + AnalyticsManager.shared.trackScreen(.registration) } } } diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift index f23aa7b..0855fdf 100644 --- a/iosApp/iosApp/Register/RegisterViewModel.swift +++ b/iosApp/iosApp/Register/RegisterViewModel.swift @@ -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) diff --git a/iosApp/iosApp/Residence/ManageUsersView.swift b/iosApp/iosApp/Residence/ManageUsersView.swift index 4ea0ab3..59773c9 100644 --- a/iosApp/iosApp/Residence/ManageUsersView.swift +++ b/iosApp/iosApp/Residence/ManageUsersView.swift @@ -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 { // 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" diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 13f9cd5..9ef6c99 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -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 { 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 { 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 diff --git a/iosApp/iosApp/Residence/ResidenceSharingManager.swift b/iosApp/iosApp/Residence/ResidenceSharingManager.swift index 3dfcfb5..3eca8c9 100644 --- a/iosApp/iosApp/Residence/ResidenceSharingManager.swift +++ b/iosApp/iosApp/Residence/ResidenceSharingManager.swift @@ -62,7 +62,7 @@ class ResidenceSharingManager: ObservableObject { guard let success = result as? ApiResultSuccess, 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) diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index b47801c..6595f02 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -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 { 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 { 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) diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 9722461..26260df 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -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 } } diff --git a/iosApp/iosApp/ResidenceFormView.swift b/iosApp/iosApp/ResidenceFormView.swift index aa25f98..013f377 100644 --- a/iosApp/iosApp/ResidenceFormView.swift +++ b/iosApp/iosApp/ResidenceFormView.swift @@ -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 } diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index 97fa2b3..15a3df9 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -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 = .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( diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index 89e1dae..e5d6421 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -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 = [] @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, let response = successResult.data, diff --git a/iosApp/iosApp/Subscription/SubscriptionCache.swift b/iosApp/iosApp/Subscription/SubscriptionCache.swift index 82dd0f9..9268070 100644 --- a/iosApp/iosApp/Subscription/SubscriptionCache.swift +++ b/iosApp/iosApp/Subscription/SubscriptionCache.swift @@ -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 { } } } - diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index b2e542d..1aafcab 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -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) diff --git a/iosApp/iosApp/Task/CompleteTaskView.swift b/iosApp/iosApp/Task/CompleteTaskView.swift index fb6f276..0c1dbc2 100644 --- a/iosApp/iosApp/Task/CompleteTaskView.swift +++ b/iosApp/iosApp/Task/CompleteTaskView.swift @@ -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: + if let success = state as? ApiResultSuccess { 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 || state is ApiResultError { + if state is ApiResultSuccess || ApiResultBridge.isError(state) { break } } @@ -470,4 +468,3 @@ struct ContractorPickerView: View { } } } - diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index 749cfb3..f33aa84 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -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 } } diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index 67a4446..de4fcb1 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -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 { 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 { diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift index 6309ae9..59b474b 100644 --- a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift @@ -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) diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 5069e2b..6f436b5 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -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")