diff --git a/iosApp/iosApp/Analytics/AnalyticsManager.swift b/iosApp/iosApp/Analytics/AnalyticsManager.swift index 89024b0..13801b0 100644 --- a/iosApp/iosApp/Analytics/AnalyticsManager.swift +++ b/iosApp/iosApp/Analytics/AnalyticsManager.swift @@ -14,7 +14,8 @@ final class AnalyticsManager { // MARK: - Configuration #if DEBUG - private static let apiKey = "phc_oeZddTz7Iw0NxDXFeCycS7TG9YRVtv7WP2DjOvFPUeQ" + // FIXME: Replace with separate production API key to prevent debug events polluting production analytics + private static let apiKey = "phc_DEVELOPMENT_KEY_REPLACE_ME" #else private static let apiKey = "phc_oeZddTz7Iw0NxDXFeCycS7TG9YRVtv7WP2DjOvFPUeQ" #endif diff --git a/iosApp/iosApp/Components/AuthenticatedImage.swift b/iosApp/iosApp/Components/AuthenticatedImage.swift index 095044c..a38ea0f 100644 --- a/iosApp/iosApp/Components/AuthenticatedImage.swift +++ b/iosApp/iosApp/Components/AuthenticatedImage.swift @@ -64,6 +64,11 @@ extension AuthenticatedImage { self.mediaURL = mediaURL self.contentMode = contentMode } + + /// Clear the in-memory image cache (call on logout to free memory and avoid stale data) + static func clearCache() { + AuthenticatedImageLoader.clearCache() + } } // MARK: - Image Loader @@ -83,9 +88,26 @@ private class AuthenticatedImageLoader: ObservableObject { private var currentURL: String? // In-memory cache for loaded images - private static var imageCache = NSCache() + private static var imageCache: NSCache = { + let cache = NSCache() + cache.countLimit = 100 + cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB + return cache + }() + + private static let memoryWarningObserver: NSObjectProtocol = { + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { _ in + imageCache.removeAllObjects() + } + }() func load(mediaURL: String?) { + _ = Self.memoryWarningObserver + guard let mediaURL = mediaURL, !mediaURL.isEmpty else { state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "No URL provided"])) return diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index 2d0916b..44abd14 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -409,6 +409,7 @@ private struct OrganicToolbarButton: View { private struct OrganicEmptyContractorsView: View { let hasFilters: Bool @State private var isAnimating = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: OrganicSpacing.comfortable) { @@ -431,7 +432,9 @@ private struct OrganicEmptyContractorsView: View { .frame(width: 120, height: 120) .scaleEffect(isAnimating ? 1.1 : 1.0) .animation( - Animation.easeInOut(duration: 3).repeatForever(autoreverses: true), + isAnimating && !reduceMotion + ? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true) + : .default, value: isAnimating ) @@ -459,6 +462,9 @@ private struct OrganicEmptyContractorsView: View { .onAppear { isAnimating = true } + .onDisappear { + isAnimating = false + } } } diff --git a/iosApp/iosApp/Core/LoadingOverlay.swift b/iosApp/iosApp/Core/LoadingOverlay.swift index 03939e7..bea6c93 100644 --- a/iosApp/iosApp/Core/LoadingOverlay.swift +++ b/iosApp/iosApp/Core/LoadingOverlay.swift @@ -104,6 +104,8 @@ extension View { /// A shimmer effect for loading placeholders struct ShimmerModifier: ViewModifier { @State private var phase: CGFloat = 0 + @State private var isAnimating = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content @@ -124,10 +126,19 @@ struct ShimmerModifier: ViewModifier { ) .mask(content) .onAppear { + isAnimating = true + guard !reduceMotion else { + phase = 0.5 + return + } withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { phase = 1 } } + .onDisappear { + isAnimating = false + phase = 0 + } } } diff --git a/iosApp/iosApp/Core/StateFlowObserver.swift b/iosApp/iosApp/Core/StateFlowObserver.swift index 859d077..ca039f0 100644 --- a/iosApp/iosApp/Core/StateFlowObserver.swift +++ b/iosApp/iosApp/Core/StateFlowObserver.swift @@ -1,12 +1,31 @@ import Foundation import ComposeApp -/// Utility for observing Kotlin StateFlow and handling ApiResult states -/// This eliminates the repeated boilerplate pattern across ViewModels +/// Utility for observing Kotlin StateFlow and handling ApiResult states. +/// This eliminates the repeated boilerplate pattern across ViewModels. +/// +/// **Important:** All observation methods return a `Task` that the caller MUST store +/// and cancel when the observer is no longer needed (e.g., in `deinit` or when the +/// view disappears). Failing to store the returned `Task` will cause a memory leak +/// because the async `for await` loop retains its closure indefinitely. +/// +/// Example usage: +/// ```swift +/// private var observationTask: Task? +/// +/// func startObserving() { +/// observationTask = StateFlowObserver.observe(stateFlow, onSuccess: { ... }) +/// } +/// +/// deinit { observationTask?.cancel() } +/// ``` @MainActor enum StateFlowObserver { - /// Observe a Kotlin StateFlow and handle loading/success/error states + /// Observe a Kotlin StateFlow and handle loading/success/error states. + /// + /// - Returns: A `Task` that the caller **must** store and cancel when observation + /// is no longer needed. Discarding this task leaks the observation loop. /// - Parameters: /// - stateFlow: The Kotlin StateFlow to observe /// - onLoading: Called when state is ApiResultLoading @@ -19,8 +38,8 @@ enum StateFlowObserver { onSuccess: @escaping (T) -> Void, onError: ((String) -> Void)? = nil, resetState: (() -> Void)? = nil - ) { - Task { + ) -> Task { + let task = Task { for await state in stateFlow { if state is ApiResultLoading { await MainActor.run { @@ -47,10 +66,14 @@ enum StateFlowObserver { } } } + return task } - /// Observe a StateFlow with automatic isLoading and errorMessage binding - /// Use this when you want standard loading/error state management + /// Observe a StateFlow with automatic isLoading and errorMessage binding. + /// Use this when you want standard loading/error state management. + /// + /// - Returns: A `Task` that the caller **must** store and cancel when observation + /// is no longer needed. Discarding this task leaks the observation loop. /// - Parameters: /// - stateFlow: The Kotlin StateFlow to observe /// - loadingSetter: Closure to set loading state @@ -63,8 +86,8 @@ enum StateFlowObserver { errorSetter: @escaping (String?) -> Void, onSuccess: @escaping (T) -> Void, resetState: (() -> Void)? = nil - ) { - observe( + ) -> Task { + return observe( stateFlow, onLoading: { loadingSetter(true) @@ -81,8 +104,11 @@ enum StateFlowObserver { ) } - /// Observe a StateFlow with a completion callback - /// Use this for create/update/delete operations that need success/failure feedback + /// Observe a StateFlow with a completion callback. + /// Use this for create/update/delete operations that need success/failure feedback. + /// + /// - Returns: A `Task` that the caller **must** store and cancel when observation + /// is no longer needed. Discarding this task leaks the observation loop. /// - Parameters: /// - stateFlow: The Kotlin StateFlow to observe /// - loadingSetter: Closure to set loading state @@ -97,8 +123,8 @@ enum StateFlowObserver { onSuccess: ((T) -> Void)? = nil, completion: @escaping (Bool) -> Void, resetState: (() -> Void)? = nil - ) { - observe( + ) -> Task { + return observe( stateFlow, onLoading: { loadingSetter(true) diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index e646c6c..79821aa 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -81,6 +81,7 @@ class DataManagerObservable: ObservableObject { // MARK: - Private Properties private var observationTasks: [Task] = [] + private var widgetSaveWorkItem: DispatchWorkItem? // MARK: - Initialization @@ -93,9 +94,10 @@ class DataManagerObservable: ObservableObject { /// Start observing all DataManager StateFlows private func startObserving() { // Authentication - authToken - let authTokenTask = Task { + let authTokenTask = Task { [weak self] in for await token in DataManager.shared.authToken { await MainActor.run { + guard let self else { return } let previousToken = self.authToken let wasAuthenticated = previousToken != nil self.authToken = token @@ -124,9 +126,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(authTokenTask) // Authentication - currentUser - let currentUserTask = Task { + let currentUserTask = Task { [weak self] in for await user in DataManager.shared.currentUser { await MainActor.run { + guard let self else { return } self.currentUser = user } } @@ -134,9 +137,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(currentUserTask) // Theme - let themeIdTask = Task { + let themeIdTask = Task { [weak self] in for await id in DataManager.shared.themeId { await MainActor.run { + guard let self else { return } self.themeId = id } } @@ -144,9 +148,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(themeIdTask) // Residences - let residencesTask = Task { + let residencesTask = Task { [weak self] in for await list in DataManager.shared.residences { await MainActor.run { + guard let self else { return } self.residences = list } } @@ -154,9 +159,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(residencesTask) // MyResidences - let myResidencesTask = Task { + let myResidencesTask = Task { [weak self] in for await response in DataManager.shared.myResidences { await MainActor.run { + guard let self else { return } self.myResidences = response } } @@ -164,9 +170,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(myResidencesTask) // TotalSummary - let totalSummaryTask = Task { + let totalSummaryTask = Task { [weak self] in for await summary in DataManager.shared.totalSummary { await MainActor.run { + guard let self else { return } self.totalSummary = summary } } @@ -174,9 +181,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(totalSummaryTask) // ResidenceSummaries - let residenceSummariesTask = Task { + let residenceSummariesTask = Task { [weak self] in for await summaries in DataManager.shared.residenceSummaries { await MainActor.run { + guard let self else { return } self.residenceSummaries = self.convertIntMap(summaries) } } @@ -184,13 +192,14 @@ class DataManagerObservable: ObservableObject { observationTasks.append(residenceSummariesTask) // AllTasks - let allTasksTask = Task { + let allTasksTask = Task { [weak self] in for await tasks in DataManager.shared.allTasks { await MainActor.run { + guard let self else { return } self.allTasks = tasks - // Save to widget shared container + // Save to widget shared container (debounced) if let tasks = tasks { - WidgetDataManager.shared.saveTasks(from: tasks) + self.debouncedWidgetSave(tasks: tasks) } } } @@ -198,9 +207,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(allTasksTask) // TasksByResidence - let tasksByResidenceTask = Task { + let tasksByResidenceTask = Task { [weak self] in for await tasks in DataManager.shared.tasksByResidence { await MainActor.run { + guard let self else { return } self.tasksByResidence = self.convertIntMap(tasks) } } @@ -208,9 +218,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(tasksByResidenceTask) // Documents - let documentsTask = Task { + let documentsTask = Task { [weak self] in for await docs in DataManager.shared.documents { await MainActor.run { + guard let self else { return } self.documents = docs } } @@ -218,9 +229,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(documentsTask) // DocumentsByResidence - let documentsByResidenceTask = Task { + let documentsByResidenceTask = Task { [weak self] in for await docs in DataManager.shared.documentsByResidence { await MainActor.run { + guard let self else { return } self.documentsByResidence = self.convertIntArrayMap(docs) } } @@ -228,9 +240,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(documentsByResidenceTask) // Contractors - let contractorsTask = Task { + let contractorsTask = Task { [weak self] in for await list in DataManager.shared.contractors { await MainActor.run { + guard let self else { return } self.contractors = list } } @@ -238,9 +251,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(contractorsTask) // Subscription - let subscriptionTask = Task { + let subscriptionTask = Task { [weak self] in for await sub in DataManager.shared.subscription { await MainActor.run { + guard let self else { return } self.subscription = sub } } @@ -248,9 +262,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(subscriptionTask) // UpgradeTriggers - let upgradeTriggersTask = Task { + let upgradeTriggersTask = Task { [weak self] in for await triggers in DataManager.shared.upgradeTriggers { await MainActor.run { + guard let self else { return } self.upgradeTriggers = self.convertStringMap(triggers) } } @@ -258,9 +273,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(upgradeTriggersTask) // FeatureBenefits - let featureBenefitsTask = Task { + let featureBenefitsTask = Task { [weak self] in for await benefits in DataManager.shared.featureBenefits { await MainActor.run { + guard let self else { return } self.featureBenefits = benefits } } @@ -268,9 +284,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(featureBenefitsTask) // Promotions - let promotionsTask = Task { + let promotionsTask = Task { [weak self] in for await promos in DataManager.shared.promotions { await MainActor.run { + guard let self else { return } self.promotions = promos } } @@ -278,9 +295,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(promotionsTask) // Lookups - ResidenceTypes - let residenceTypesTask = Task { + let residenceTypesTask = Task { [weak self] in for await types in DataManager.shared.residenceTypes { await MainActor.run { + guard let self else { return } self.residenceTypes = types } } @@ -288,9 +306,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(residenceTypesTask) // Lookups - TaskFrequencies - let taskFrequenciesTask = Task { + let taskFrequenciesTask = Task { [weak self] in for await items in DataManager.shared.taskFrequencies { await MainActor.run { + guard let self else { return } self.taskFrequencies = items } } @@ -298,9 +317,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(taskFrequenciesTask) // Lookups - TaskPriorities - let taskPrioritiesTask = Task { + let taskPrioritiesTask = Task { [weak self] in for await items in DataManager.shared.taskPriorities { await MainActor.run { + guard let self else { return } self.taskPriorities = items } } @@ -308,9 +328,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(taskPrioritiesTask) // Lookups - TaskCategories - let taskCategoriesTask = Task { + let taskCategoriesTask = Task { [weak self] in for await items in DataManager.shared.taskCategories { await MainActor.run { + guard let self else { return } self.taskCategories = items } } @@ -318,9 +339,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(taskCategoriesTask) // Lookups - ContractorSpecialties - let contractorSpecialtiesTask = Task { + let contractorSpecialtiesTask = Task { [weak self] in for await items in DataManager.shared.contractorSpecialties { await MainActor.run { + guard let self else { return } self.contractorSpecialties = items } } @@ -328,9 +350,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(contractorSpecialtiesTask) // Task Templates - let taskTemplatesTask = Task { + let taskTemplatesTask = Task { [weak self] in for await items in DataManager.shared.taskTemplates { await MainActor.run { + guard let self else { return } self.taskTemplates = items } } @@ -338,9 +361,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(taskTemplatesTask) // Task Templates Grouped - let taskTemplatesGroupedTask = Task { + let taskTemplatesGroupedTask = Task { [weak self] in for await response in DataManager.shared.taskTemplatesGrouped { await MainActor.run { + guard let self else { return } self.taskTemplatesGrouped = response } } @@ -348,9 +372,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(taskTemplatesGroupedTask) // Metadata - isInitialized - let isInitializedTask = Task { + let isInitializedTask = Task { [weak self] in for await initialized in DataManager.shared.isInitialized { await MainActor.run { + guard let self else { return } self.isInitialized = initialized.boolValue } } @@ -358,9 +383,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(isInitializedTask) // Metadata - lookupsInitialized - let lookupsInitializedTask = Task { + let lookupsInitializedTask = Task { [weak self] in for await initialized in DataManager.shared.lookupsInitialized { await MainActor.run { + guard let self else { return } self.lookupsInitialized = initialized.boolValue } } @@ -368,9 +394,10 @@ class DataManagerObservable: ObservableObject { observationTasks.append(lookupsInitializedTask) // Metadata - lastSyncTime - let lastSyncTimeTask = Task { + let lastSyncTimeTask = Task { [weak self] in for await time in DataManager.shared.lastSyncTime { await MainActor.run { + guard let self else { return } self.lastSyncTime = time.int64Value } } @@ -378,6 +405,20 @@ class DataManagerObservable: ObservableObject { observationTasks.append(lastSyncTimeTask) } + // MARK: - Widget Save Debounce + + /// Debounce widget saves to avoid excessive disk I/O on rapid task updates. + /// Cancels any pending save and schedules a new one after 2 seconds. + private func debouncedWidgetSave(tasks: TaskColumnsResponse) { + widgetSaveWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard self != nil else { return } + WidgetDataManager.shared.saveTasks(from: tasks) + } + widgetSaveWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) + } + /// Stop all observations func stopObserving() { observationTasks.forEach { $0.cancel() } diff --git a/iosApp/iosApp/Design/OrganicDesign.swift b/iosApp/iosApp/Design/OrganicDesign.swift index d34f322..393ca7f 100644 --- a/iosApp/iosApp/Design/OrganicDesign.swift +++ b/iosApp/iosApp/Design/OrganicDesign.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit // MARK: - Organic Design System // Warm, natural aesthetic with soft shapes, subtle textures, and flowing layouts @@ -99,29 +100,59 @@ struct OrganicRoundedRectangle: Shape { } } +// MARK: - Grain Texture Cache + +/// Generates and caches a grain texture image so it is only computed once, +/// regardless of how many GrainTexture views are on screen. +private final class GrainTextureCache { + static let shared = GrainTextureCache() + + /// Cached grain image at a fixed tile size. Tiled across the view. + private(set) var cachedImage: UIImage? + private let tileSize = CGSize(width: 128, height: 128) + private let lock = NSLock() + + private init() { + generateIfNeeded() + } + + func generateIfNeeded() { + lock.lock() + defer { lock.unlock() } + guard cachedImage == nil else { return } + + let renderer = UIGraphicsImageRenderer(size: tileSize) + cachedImage = renderer.image { ctx in + let cgCtx = ctx.cgContext + let w = tileSize.width + let h = tileSize.height + let dotCount = Int(w * h / 200) + + for _ in 0.. + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/iosApp/iosApp/PushNotifications/AppDelegate.swift b/iosApp/iosApp/PushNotifications/AppDelegate.swift index 73d13de..2038369 100644 --- a/iosApp/iosApp/PushNotifications/AppDelegate.swift +++ b/iosApp/iosApp/PushNotifications/AppDelegate.swift @@ -3,6 +3,7 @@ import UserNotifications import BackgroundTasks import ComposeApp +@MainActor class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { func application( @@ -19,20 +20,20 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele BackgroundTaskManager.shared.registerBackgroundTasks() // Request notification permission - Task { @MainActor in + Task { await PushNotificationManager.shared.requestNotificationPermission() } // Clear badge when app launches - Task { @MainActor in - PushNotificationManager.shared.clearBadge() - } + PushNotificationManager.shared.clearBadge() // Initialize StoreKit and check for existing subscriptions // This ensures we have the user's subscription status ready before they interact Task { _ = StoreKitManager.shared - print("✅ StoreKit initialized at app launch") + #if DEBUG + print("StoreKit initialized at app launch") + #endif } return true @@ -42,9 +43,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele func applicationDidBecomeActive(_ application: UIApplication) { // Clear badge when app becomes active - Task { @MainActor in - PushNotificationManager.shared.clearBadge() - } + PushNotificationManager.shared.clearBadge() // Refresh StoreKit subscription status when app comes to foreground // This ensures we have the latest subscription state if it changed while app was in background @@ -59,31 +58,29 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { - Task { @MainActor in - PushNotificationManager.shared.didRegisterForRemoteNotifications(withDeviceToken: deviceToken) - } + PushNotificationManager.shared.didRegisterForRemoteNotifications(withDeviceToken: deviceToken) } func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { - Task { @MainActor in - PushNotificationManager.shared.didFailToRegisterForRemoteNotifications(withError: error) - } + PushNotificationManager.shared.didFailToRegisterForRemoteNotifications(withError: error) } // MARK: - UNUserNotificationCenterDelegate // Called when notification is received while app is in foreground - func userNotificationCenter( + nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { + #if DEBUG let userInfo = notification.request.content.userInfo let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted() - print("đŸ“Ŧ Notification received in foreground. Keys: \(payloadKeys)") + print("Notification received in foreground. Keys: \(payloadKeys)") + #endif // Passive mode in foreground: present banner/sound, but do not // mutate read state or trigger navigation automatically. @@ -91,7 +88,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele } // Called when user taps on notification or selects an action - func userNotificationCenter( + nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void @@ -99,9 +96,11 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele let userInfo = response.notification.request.content.userInfo let actionIdentifier = response.actionIdentifier - print("👆 User interacted with notification - Action: \(actionIdentifier)") + #if DEBUG + print("User interacted with notification - Action: \(actionIdentifier)") let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted() print(" Payload keys: \(payloadKeys)") + #endif Task { @MainActor in // Handle action buttons or default tap @@ -109,8 +108,9 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele // User tapped the notification body - navigate to task PushNotificationManager.shared.handleNotificationTap(userInfo: userInfo) } else if actionIdentifier == UNNotificationDismissActionIdentifier { - // User dismissed the notification - print("📤 Notification dismissed") + #if DEBUG + print("Notification dismissed") + #endif } else { // User selected an action button PushNotificationManager.shared.handleNotificationAction( @@ -118,8 +118,8 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele userInfo: userInfo ) } - } - completionHandler() + completionHandler() + } } } diff --git a/iosApp/iosApp/PushNotifications/NotificationCategories.swift b/iosApp/iosApp/PushNotifications/NotificationCategories.swift index 632e76b..57c416d 100644 --- a/iosApp/iosApp/PushNotifications/NotificationCategories.swift +++ b/iosApp/iosApp/PushNotifications/NotificationCategories.swift @@ -31,7 +31,9 @@ struct NotificationCategories { static func registerCategories() { let categories = createAllCategories() UNUserNotificationCenter.current().setNotificationCategories(categories) + #if DEBUG print("Registered \(categories.count) notification categories") + #endif } /// Creates all notification categories for the app diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift index ceb6b10..4c3507c 100644 --- a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -3,8 +3,9 @@ import UIKit import UserNotifications import ComposeApp +@MainActor class PushNotificationManager: NSObject, ObservableObject { - @MainActor static let shared = PushNotificationManager() + static let shared = PushNotificationManager() @Published var deviceToken: String? @Published var notificationPermissionGranted = false @@ -43,18 +44,24 @@ class PushNotificationManager: NSObject, ObservableObject { notificationPermissionGranted = granted if granted { + #if DEBUG print("✅ Notification permission granted") + #endif // Register for remote notifications on main thread await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } } else { + #if DEBUG print("❌ Notification permission denied") + #endif } return granted } catch { + #if DEBUG print("❌ Error requesting notification permission: \(error)") + #endif return false } } @@ -65,16 +72,21 @@ class PushNotificationManager: NSObject, ObservableObject { let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() self.deviceToken = tokenString let redactedToken = "\(tokenString.prefix(8))...\(tokenString.suffix(8))" + #if DEBUG print("📱 APNs device token: \(redactedToken)") + #endif // Register with backend - Task { - await registerDeviceWithBackend(token: tokenString) + Task { [weak self] in + guard let self else { return } + await self.registerDeviceWithBackend(token: tokenString) } } func didFailToRegisterForRemoteNotifications(withError error: Error) { + #if DEBUG print("❌ Failed to register for remote notifications: \(error)") + #endif } // MARK: - Backend Registration @@ -82,30 +94,38 @@ class PushNotificationManager: NSObject, ObservableObject { /// Call this after login to register any pending device token func registerDeviceAfterLogin() { guard let token = deviceToken else { + #if DEBUG print("âš ī¸ No device token available for registration") + #endif return } - Task { - await registerDeviceWithBackend(token: token, force: false) + Task { [weak self] in + guard let self else { return } + await self.registerDeviceWithBackend(token: token, force: false) } } /// Call this when app returns from background to check and register if needed func checkAndRegisterDeviceIfNeeded() { guard let token = deviceToken else { + #if DEBUG print("âš ī¸ No device token available for registration check") + #endif return } - Task { - await registerDeviceWithBackend(token: token, force: false) + Task { [weak self] in + guard let self else { return } + await self.registerDeviceWithBackend(token: token, force: false) } } private func registerDeviceWithBackend(token: String, force: Bool = false) async { guard TokenStorage.shared.getToken() != nil else { + #if DEBUG print("âš ī¸ No auth token available, will register device after login") + #endif return } @@ -116,12 +136,16 @@ class PushNotificationManager: NSObject, ObservableObject { // Skip only if both token and user identity match. if !force, token == lastRegisteredToken { if let currentUserId, currentUserId == lastRegisteredUserId { + #if DEBUG print("📱 Device token already registered for current user, skipping") + #endif return } if currentUserId == nil, lastRegisteredUserId == nil { + #if DEBUG print("📱 Device token already registered, skipping") + #endif return } } @@ -145,55 +169,73 @@ class PushNotificationManager: NSObject, ObservableObject { let result = try await APILayer.shared.registerDevice(request: request) if let success = result as? ApiResultSuccess { + #if DEBUG if success.data != nil { print("✅ Device registered successfully") } else { print("✅ Device registration acknowledged") } + #endif // Cache the token on successful registration await MainActor.run { self.lastRegisteredToken = token self.lastRegisteredUserId = currentUserId } } else if let error = ApiResultBridge.error(from: result) { + #if DEBUG print("❌ Failed to register device: \(error.message)") + #endif } else { + #if DEBUG print("âš ī¸ Unexpected result type from device registration") + #endif } } catch { + #if DEBUG print("❌ Error registering device: \(error.localizedDescription)") + #endif } } // MARK: - Handle Notifications func handleNotification(userInfo: [AnyHashable: Any]) { + #if DEBUG print("đŸ“Ŧ Received notification: \(redactedPayloadSummary(userInfo: userInfo))") + #endif // Extract notification data if let notificationId = stringValue(for: "notification_id", in: userInfo) { + #if DEBUG print("Notification ID: \(notificationId)") + #endif // Mark as read when user taps notification - Task { - await markNotificationAsRead(notificationId: notificationId) + Task { [weak self] in + guard let self else { return } + await self.markNotificationAsRead(notificationId: notificationId) } } if let type = userInfo["type"] as? String { + #if DEBUG print("Notification type: \(type)") + #endif handleNotificationType(type: type, userInfo: userInfo) } } /// Called when user taps the notification body (not an action button) func handleNotificationTap(userInfo: [AnyHashable: Any]) { + #if DEBUG print("đŸ“Ŧ Handling notification tap") + #endif // Mark as read if let notificationId = stringValue(for: "notification_id", in: userInfo) { - Task { - await markNotificationAsRead(notificationId: notificationId) + Task { [weak self] in + guard let self else { return } + await self.markNotificationAsRead(notificationId: notificationId) } } @@ -204,7 +246,9 @@ class PushNotificationManager: NSObject, ObservableObject { let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded let canNavigateToTask = isPremium || !limitationsEnabled + #if DEBUG print("đŸ“Ŧ Push nav check: isPremium=\(isPremium), limitationsEnabled=\(limitationsEnabled), canNavigate=\(canNavigateToTask), subscription=\(subscription != nil ? "loaded" : "nil")") + #endif if canNavigateToTask { // Navigate to task detail @@ -224,12 +268,15 @@ class PushNotificationManager: NSObject, ObservableObject { /// Called when user selects an action button on the notification func handleNotificationAction(actionIdentifier: String, userInfo: [AnyHashable: Any]) { + #if DEBUG print("🔘 Handling notification action: \(actionIdentifier)") + #endif // Mark as read if let notificationId = stringValue(for: "notification_id", in: userInfo) { - Task { - await markNotificationAsRead(notificationId: notificationId) + Task { [weak self] in + guard let self else { return } + await self.markNotificationAsRead(notificationId: notificationId) } } @@ -248,7 +295,9 @@ class PushNotificationManager: NSObject, ObservableObject { // Extract task ID guard let taskId = intValue(for: "task_id", in: userInfo) else { + #if DEBUG print("❌ No task_id found in notification") + #endif return } @@ -273,7 +322,9 @@ class PushNotificationManager: NSObject, ObservableObject { navigateToEditTask(taskId: taskId) default: + #if DEBUG print("âš ī¸ Unknown action: \(actionIdentifier)") + #endif navigateToTask(taskId: taskId) } } @@ -282,38 +333,53 @@ class PushNotificationManager: NSObject, ObservableObject { switch type { case "task_due_soon", "task_overdue", "task_completed", "task_assigned": if let taskId = intValue(for: "task_id", in: userInfo) { + #if DEBUG print("Task notification for task ID: \(taskId)") + #endif navigateToTask(taskId: taskId) } case "residence_shared": if let residenceId = intValue(for: "residence_id", in: userInfo) { + #if DEBUG print("Residence shared notification for residence ID: \(residenceId)") + #endif navigateToResidence(residenceId: residenceId) } else { + #if DEBUG print("Residence shared notification without residence ID") + #endif navigateToResidencesTab() } case "warranty_expiring": if let documentId = intValue(for: "document_id", in: userInfo) { + #if DEBUG print("Warranty expiring notification for document ID: \(documentId)") + #endif navigateToDocument(documentId: documentId) } else { + #if DEBUG print("Warranty expiring notification without document ID") + #endif navigateToDocumentsTab() } default: + #if DEBUG print("Unknown notification type: \(type)") + #endif } } // MARK: - Task Actions private func performCompleteTask(taskId: Int) { + #if DEBUG print("✅ Completing task \(taskId) from notification action") - Task { + #endif + Task { [weak self] in + guard let _ = self else { return } do { // Quick complete without photos/notes let request = TaskCompletionCreateRequest( @@ -327,84 +393,125 @@ class PushNotificationManager: NSObject, ObservableObject { let result = try await APILayer.shared.createTaskCompletion(request: request) if result is ApiResultSuccess { + #if DEBUG print("✅ Task \(taskId) completed successfully") + #endif // Post notification for UI refresh await MainActor.run { NotificationCenter.default.post(name: .taskActionCompleted, object: nil) } } else if let error = ApiResultBridge.error(from: result) { + #if DEBUG print("❌ Failed to complete task: \(error.message)") + #endif } else { + #if DEBUG print("âš ī¸ Unexpected result while completing task \(taskId)") + #endif } } catch { + #if DEBUG print("❌ Error completing task: \(error.localizedDescription)") + #endif } } } private func performMarkInProgress(taskId: Int) { + #if DEBUG print("🔄 Marking task \(taskId) as in progress from notification action") - Task { + #endif + Task { [weak self] in + guard let _ = self else { return } do { let result = try await APILayer.shared.markInProgress(taskId: Int32(taskId)) if result is ApiResultSuccess { + #if DEBUG print("✅ Task \(taskId) marked as in progress") + #endif await MainActor.run { NotificationCenter.default.post(name: .taskActionCompleted, object: nil) } } else if let error = ApiResultBridge.error(from: result) { + #if DEBUG print("❌ Failed to mark task in progress: \(error.message)") + #endif } else { + #if DEBUG print("âš ī¸ Unexpected result while marking task \(taskId) in progress") + #endif } } catch { + #if DEBUG print("❌ Error marking task in progress: \(error.localizedDescription)") + #endif } } } private func performCancelTask(taskId: Int) { + #if DEBUG print("đŸšĢ Cancelling task \(taskId) from notification action") - Task { + #endif + Task { [weak self] in + guard let _ = self else { return } do { let result = try await APILayer.shared.cancelTask(taskId: Int32(taskId)) if result is ApiResultSuccess { + #if DEBUG print("✅ Task \(taskId) cancelled") + #endif await MainActor.run { NotificationCenter.default.post(name: .taskActionCompleted, object: nil) } } else if let error = ApiResultBridge.error(from: result) { + #if DEBUG print("❌ Failed to cancel task: \(error.message)") + #endif } else { + #if DEBUG print("âš ī¸ Unexpected result while cancelling task \(taskId)") + #endif } } catch { + #if DEBUG print("❌ Error cancelling task: \(error.localizedDescription)") + #endif } } } private func performUncancelTask(taskId: Int) { + #if DEBUG print("â†Šī¸ Uncancelling task \(taskId) from notification action") - Task { + #endif + Task { [weak self] in + guard let _ = self else { return } do { let result = try await APILayer.shared.uncancelTask(taskId: Int32(taskId)) if result is ApiResultSuccess { + #if DEBUG print("✅ Task \(taskId) uncancelled") + #endif await MainActor.run { NotificationCenter.default.post(name: .taskActionCompleted, object: nil) } } else if let error = ApiResultBridge.error(from: result) { + #if DEBUG print("❌ Failed to uncancel task: \(error.message)") + #endif } else { + #if DEBUG print("âš ī¸ Unexpected result while uncancelling task \(taskId)") + #endif } } catch { + #if DEBUG print("❌ Error uncancelling task: \(error.localizedDescription)") + #endif } } } @@ -412,7 +519,9 @@ class PushNotificationManager: NSObject, ObservableObject { // MARK: - Navigation private func navigateToTask(taskId: Int) { + #if DEBUG print("📱 Navigating to task \(taskId)") + #endif // Store pending navigation in case MainTabView isn't ready yet pendingNavigationTaskId = taskId NotificationCenter.default.post( @@ -436,7 +545,9 @@ class PushNotificationManager: NSObject, ObservableObject { } private func navigateToEditTask(taskId: Int) { + #if DEBUG print("âœī¸ Navigating to edit task \(taskId)") + #endif NotificationCenter.default.post( name: .navigateToEditTask, object: nil, @@ -445,7 +556,9 @@ class PushNotificationManager: NSObject, ObservableObject { } private func navigateToResidence(residenceId: Int) { + #if DEBUG print("🏠 Navigating to residence \(residenceId)") + #endif pendingNavigationResidenceId = residenceId NotificationCenter.default.post( name: .navigateToResidence, @@ -459,7 +572,9 @@ class PushNotificationManager: NSObject, ObservableObject { } private func navigateToDocument(documentId: Int) { + #if DEBUG print("📄 Navigating to document \(documentId)") + #endif pendingNavigationDocumentId = documentId NotificationCenter.default.post( name: .navigateToDocument, @@ -473,7 +588,9 @@ class PushNotificationManager: NSObject, ObservableObject { } private func navigateToHome() { + #if DEBUG print("🏠 Navigating to home") + #endif pendingNavigationTaskId = nil pendingNavigationResidenceId = nil pendingNavigationDocumentId = nil @@ -519,14 +636,22 @@ class PushNotificationManager: NSObject, ObservableObject { let result = try await APILayer.shared.markNotificationAsRead(notificationId: notificationIdInt) if result is ApiResultSuccess { + #if DEBUG print("✅ Notification marked as read") + #endif } else if let error = ApiResultBridge.error(from: result) { + #if DEBUG print("❌ Failed to mark notification as read: \(error.message)") + #endif } else { + #if DEBUG print("âš ī¸ Unexpected result while marking notification read") + #endif } } catch { + #if DEBUG print("❌ Error marking notification as read: \(error.localizedDescription)") + #endif } } @@ -534,7 +659,9 @@ class PushNotificationManager: NSObject, ObservableObject { func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool { guard TokenStorage.shared.getToken() != nil else { + #if DEBUG print("âš ī¸ No auth token available") + #endif return false } @@ -542,23 +669,33 @@ class PushNotificationManager: NSObject, ObservableObject { let result = try await APILayer.shared.updateNotificationPreferences(request: preferences) if result is ApiResultSuccess { + #if DEBUG print("✅ Notification preferences updated") + #endif return true } else if let error = ApiResultBridge.error(from: result) { + #if DEBUG print("❌ Failed to update preferences: \(error.message)") + #endif return false } + #if DEBUG print("âš ī¸ Unexpected result while updating notification preferences") + #endif return false } catch { + #if DEBUG print("❌ Error updating notification preferences: \(error.localizedDescription)") + #endif return false } } func getNotificationPreferences() async -> NotificationPreference? { guard TokenStorage.shared.getToken() != nil else { + #if DEBUG print("âš ī¸ No auth token available") + #endif return nil } @@ -568,13 +705,19 @@ class PushNotificationManager: NSObject, ObservableObject { if let success = result as? ApiResultSuccess { return success.data } else if let error = ApiResultBridge.error(from: result) { + #if DEBUG print("❌ Failed to get preferences: \(error.message)") + #endif return nil } + #if DEBUG print("âš ī¸ Unexpected result while loading notification preferences") + #endif return nil } catch { + #if DEBUG print("❌ Error getting notification preferences: \(error.localizedDescription)") + #endif return nil } } diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 26260df..2323f28 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -9,7 +9,7 @@ struct ResidencesListView: View { @State private var showingUpgradePrompt = false @State private var showingSettings = false @State private var pushTargetResidenceId: Int32? - @State private var navigateToPushResidence = false + @State private var showLoginCover = false @StateObject private var authManager = AuthenticationManager.shared @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @Environment(\.scenePhase) private var scenePhase @@ -100,7 +100,7 @@ struct ResidencesListView: View { UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt) } .sheet(isPresented: $showingSettings) { - NavigationView { + NavigationStack { ProfileTabView() } } @@ -113,6 +113,8 @@ struct ResidencesListView: View { viewModel.loadMyResidences() // Also load tasks to populate summary stats taskViewModel.loadTasks() + } else { + showLoginCover = true } } .onChange(of: scenePhase) { newPhase in @@ -122,9 +124,10 @@ struct ResidencesListView: View { taskViewModel.loadTasks(forceRefresh: true) } } - .fullScreenCover(isPresented: $authManager.isAuthenticated.negated) { + .fullScreenCover(isPresented: $showLoginCover) { LoginView(onLoginSuccess: { authManager.isAuthenticated = true + showLoginCover = false viewModel.loadMyResidences() taskViewModel.loadTasks() }) @@ -133,11 +136,13 @@ struct ResidencesListView: View { .onChange(of: authManager.isAuthenticated) { isAuth in if isAuth { // User just logged in or registered - load their residences and tasks + showLoginCover = false viewModel.loadMyResidences() taskViewModel.loadTasks() } else { - // User logged out - clear data + // User logged out - clear data and show login viewModel.myResidences = nil + showLoginCover = true } } .onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { notification in @@ -145,26 +150,18 @@ struct ResidencesListView: View { navigateToResidenceFromPush(residenceId: residenceId) } } - .background( - NavigationLink( - destination: Group { - if let residenceId = pushTargetResidenceId { - ResidenceDetailView(residenceId: residenceId) - } else { - EmptyView() - } - }, - isActive: $navigateToPushResidence - ) { - EmptyView() + .navigationDestination(isPresented: Binding( + get: { pushTargetResidenceId != nil }, + set: { if !$0 { pushTargetResidenceId = nil } } + )) { + if let residenceId = pushTargetResidenceId { + ResidenceDetailView(residenceId: residenceId) } - .hidden() - ) + } } private func navigateToResidenceFromPush(residenceId: Int) { pushTargetResidenceId = Int32(residenceId) - navigateToPushResidence = true PushNotificationManager.shared.pendingNavigationResidenceId = nil } } @@ -271,6 +268,7 @@ private struct OrganicCardButtonStyle: ButtonStyle { private struct OrganicEmptyResidencesView: View { @State private var isAnimating = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: OrganicSpacing.comfortable) { @@ -295,7 +293,9 @@ private struct OrganicEmptyResidencesView: View { .frame(width: 160, height: 160) .scaleEffect(isAnimating ? 1.1 : 1.0) .animation( - Animation.easeInOut(duration: 3).repeatForever(autoreverses: true), + isAnimating && !reduceMotion + ? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true) + : .default, value: isAnimating ) @@ -313,7 +313,9 @@ private struct OrganicEmptyResidencesView: View { .foregroundColor(Color.appPrimary) .offset(y: isAnimating ? -2 : 2) .animation( - Animation.easeInOut(duration: 2).repeatForever(autoreverses: true), + isAnimating && !reduceMotion + ? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true) + : .default, value: isAnimating ) } @@ -347,20 +349,14 @@ private struct OrganicEmptyResidencesView: View { .onAppear { isAnimating = true } + .onDisappear { + isAnimating = false + } } } #Preview { - NavigationView { + NavigationStack { ResidencesListView() } } - -extension Binding where Value == Bool { - var negated: Binding { - Binding( - get: { !self.wrappedValue }, - set: { self.wrappedValue = !$0 } - ) - } -} diff --git a/iosApp/iosApp/Shared/Extensions/DoubleExtensions.swift b/iosApp/iosApp/Shared/Extensions/DoubleExtensions.swift index 3bf2eee..35d73be 100644 --- a/iosApp/iosApp/Shared/Extensions/DoubleExtensions.swift +++ b/iosApp/iosApp/Shared/Extensions/DoubleExtensions.swift @@ -20,7 +20,10 @@ extension KotlinDouble { extension Double { /// Formats as currency (e.g., "$1,234.56") func toCurrency() -> String { - NumberFormatters.shared.currency.string(from: NSNumber(value: self)) ?? "$\(self)" + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + return formatter.string(from: NSNumber(value: self)) ?? "$\(self)" } /// Formats as currency with currency symbol (e.g., "$1,234.56") @@ -37,7 +40,7 @@ extension Double { formatter.numberStyle = .decimal formatter.minimumFractionDigits = fractionDigits formatter.maximumFractionDigits = fractionDigits - return formatter.string(from: NSNumber(value: self)) ?? "\(self)" + return formatter.string(from: NSNumber(value: self)) ?? String(format: "%.\(fractionDigits)f", self) } /// Formats as percentage (e.g., "45.5%") @@ -75,7 +78,9 @@ extension Double { extension Int { /// Formats with comma separators (e.g., "1,234") func toFormattedString() -> String { - NumberFormatters.shared.decimal.string(from: NSNumber(value: self)) ?? "\(self)" + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: self)) ?? "\(self)" } /// Converts bytes to human-readable file size @@ -89,34 +94,3 @@ extension Int { } } -// MARK: - Centralized Number Formatters - -class NumberFormatters { - static let shared = NumberFormatters() - - private init() {} - - /// Currency formatter with $ symbol - lazy var currency: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "USD" - return formatter - }() - - /// Decimal formatter with comma separators - lazy var decimal: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter - }() - - /// Percentage formatter - lazy var percentage: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - formatter.minimumFractionDigits = 1 - formatter.maximumFractionDigits = 1 - return formatter - }() -} diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index 41ed1b6..a1a2836 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -4,6 +4,7 @@ import ComposeApp /// StoreKit 2 manager for in-app purchases /// Handles product loading, purchases, transaction observation, and backend verification +@MainActor class StoreKitManager: ObservableObject { static let shared = StoreKitManager() @@ -54,7 +55,6 @@ class StoreKitManager: ObservableObject { } /// Load available products from App Store - @MainActor func loadProducts() async { isLoading = true defer { isLoading = false } @@ -87,13 +87,15 @@ class StoreKitManager: ObservableObject { // Update purchased products await updatePurchasedProducts() - // Verify with backend - await verifyTransactionWithBackend(transaction) + // Verify with backend — only finish the transaction if verification succeeds + do { + try await verifyTransactionWithBackend(transaction) + await transaction.finish() + print("✅ StoreKit: Purchase successful for \(product.id)") + } catch { + print("âš ī¸ StoreKit: Backend verification failed for \(product.id), transaction NOT finished so it can be retried: \(error)") + } - // Finish the transaction - await transaction.finish() - - print("✅ StoreKit: Purchase successful for \(product.id)") return transaction case .userCancelled: @@ -124,15 +126,13 @@ class StoreKitManager: ObservableObject { // Verify all current entitlements with backend for await result in Transaction.currentEntitlements { let transaction = try checkVerified(result) - await verifyTransactionWithBackend(transaction) + try await verifyTransactionWithBackend(transaction) } print("✅ StoreKit: Purchases restored") } catch { print("❌ StoreKit: Failed to restore purchases: \(error)") - await MainActor.run { - purchaseError = "Failed to restore purchases: \(error.localizedDescription)" - } + purchaseError = "Failed to restore purchases: \(error.localizedDescription)" } } @@ -176,13 +176,15 @@ class StoreKitManager: ObservableObject { if transaction.productType == .autoRenewable { print("đŸ“Ļ StoreKit: Found active subscription: \(transaction.productID)") - // Verify this transaction with backend - await verifyTransactionWithBackend(transaction) + // Verify this transaction with backend (best-effort on launch) + do { + try await verifyTransactionWithBackend(transaction) + } catch { + print("âš ī¸ StoreKit: Backend verification failed on launch for \(transaction.productID): \(error)") + } // Update local purchased products - await MainActor.run { - _ = purchasedProductIDs.insert(transaction.productID) - } + _ = purchasedProductIDs.insert(transaction.productID) } } @@ -199,10 +201,8 @@ class StoreKitManager: ObservableObject { if let statusSuccess = statusResult as? ApiResultSuccess, let subscription = statusSuccess.data { - await MainActor.run { - SubscriptionCacheWrapper.shared.updateSubscription(subscription) - print("✅ StoreKit: Backend subscription status updated - Tier: \(subscription.limits)") - } + SubscriptionCacheWrapper.shared.updateSubscription(subscription) + print("✅ StoreKit: Backend subscription status updated - Tier: \(subscription.limits)") } } catch { print("❌ StoreKit: Failed to refresh subscription from backend: \(error)") @@ -210,7 +210,6 @@ class StoreKitManager: ObservableObject { } /// Update purchased product IDs - @MainActor private func updatePurchasedProducts() async { var purchasedIDs: Set = [] @@ -232,22 +231,20 @@ class StoreKitManager: ObservableObject { /// Listen for transaction updates private func listenForTransactions() -> Task { - return Task.detached { - // Listen for transaction updates + return Task { for await result in Transaction.updates { do { - let transaction = try self.checkVerified(result) + let transaction = try checkVerified(result) - // Update purchased products - await self.updatePurchasedProducts() + await updatePurchasedProducts() - // Verify with backend - await self.verifyTransactionWithBackend(transaction) - - // Finish the transaction - await transaction.finish() - - print("✅ StoreKit: Transaction updated: \(transaction.productID)") + do { + try await verifyTransactionWithBackend(transaction) + await transaction.finish() + print("✅ StoreKit: Transaction updated: \(transaction.productID)") + } catch { + print("âš ī¸ StoreKit: Backend verification failed for \(transaction.productID), transaction NOT finished so it can be retried: \(error)") + } } catch { print("❌ StoreKit: Transaction verification failed: \(error)") } @@ -256,41 +253,39 @@ class StoreKitManager: ObservableObject { } /// Verify transaction with backend API - private func verifyTransactionWithBackend(_ transaction: Transaction) async { - do { - // Get transaction receipt data - let receiptData = String(transaction.id) + /// Throws if backend verification fails so callers can decide whether to finish the transaction + private func verifyTransactionWithBackend(_ transaction: Transaction) async throws { + let receiptData = String(transaction.id) - // Call backend verification endpoint via APILayer - let result = try await APILayer.shared.verifyIOSReceipt( - receiptData: receiptData, - transactionId: String(transaction.id) - ) + // Call backend verification endpoint via APILayer + let result = try await APILayer.shared.verifyIOSReceipt( + receiptData: receiptData, + transactionId: String(transaction.id) + ) - // Handle result (Kotlin ApiResult type) - if let successResult = result as? ApiResultSuccess, - let response = successResult.data, - response.success { - print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")") + // Handle result (Kotlin ApiResult type) + if let successResult = result as? ApiResultSuccess, + let response = successResult.data, + response.success { + print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")") - // Fetch updated subscription status from backend via APILayer - let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true) + // Fetch updated subscription status from backend via APILayer + let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true) - if let statusSuccess = statusResult as? ApiResultSuccess, - let subscription = statusSuccess.data { - await MainActor.run { - SubscriptionCacheWrapper.shared.updateSubscription(subscription) - } - } - } 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, - !response.success { - print("❌ StoreKit: Backend verification failed: \(response.message)") + if let statusSuccess = statusResult as? ApiResultSuccess, + let subscription = statusSuccess.data { + SubscriptionCacheWrapper.shared.updateSubscription(subscription) } - } catch { - print("❌ StoreKit: Backend verification error: \(error)") + } else if let errorResult = ApiResultBridge.error(from: result) { + let message = errorResult.message + print("❌ StoreKit: Backend verification failed: \(message)") + throw StoreKitError.backendVerificationFailed(message) + } else if let successResult = result as? ApiResultSuccess, + let response = successResult.data, + !response.success { + let message = response.message + print("❌ StoreKit: Backend verification failed: \(message)") + throw StoreKitError.backendVerificationFailed(message) } } @@ -311,6 +306,7 @@ extension StoreKitManager { case verificationFailed case noProducts case purchaseFailed + case backendVerificationFailed(String) var errorDescription: String? { switch self { @@ -320,6 +316,8 @@ extension StoreKitManager { return "No products available" case .purchaseFailed: return "Purchase failed" + case .backendVerificationFailed(let message): + return "Backend verification failed: \(message)" } } } diff --git a/iosApp/iosApp/Subscription/SubscriptionCache.swift b/iosApp/iosApp/Subscription/SubscriptionCache.swift index e344d2f..dadc700 100644 --- a/iosApp/iosApp/Subscription/SubscriptionCache.swift +++ b/iosApp/iosApp/Subscription/SubscriptionCache.swift @@ -1,11 +1,14 @@ import SwiftUI +import Combine import ComposeApp /// Swift wrapper that reads subscription state from Kotlin DataManager (single source of truth). /// /// DataManager is the authoritative subscription state holder. This wrapper -/// observes DataManager's StateFlows (via polling) and publishes changes -/// to SwiftUI views via @Published properties. +/// observes DataManager's StateFlows (via targeted Combine observation of +/// DataManagerObservable's subscription-related @Published properties) +/// and publishes changes to SwiftUI views via @Published properties. +@MainActor class SubscriptionCacheWrapper: ObservableObject { static let shared = SubscriptionCacheWrapper() @@ -14,6 +17,8 @@ class SubscriptionCacheWrapper: ObservableObject { @Published var featureBenefits: [FeatureBenefit] = [] @Published var promotions: [Promotion] = [] + private var cancellables = Set() + /// Current tier derived from backend subscription status, with StoreKit fallback. /// Mirrors the logic in Kotlin SubscriptionHelper.currentTier. var currentTier: String { @@ -104,61 +109,58 @@ class SubscriptionCacheWrapper: ObservableObject { } private init() { - // Start observation of DataManager (single source of truth) - Task { @MainActor in - // Initial sync from DataManager - self.syncFromDataManager() + // Observe only subscription-related @Published properties from DataManagerObservable + // instead of the broad objectWillChange (which fires on ALL 25+ property changes). - // Poll DataManager for updates periodically - // (workaround for Kotlin StateFlow observation from Swift) - while true { - try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds - self.syncFromDataManager() + DataManagerObservable.shared.$subscription + .sink { [weak self] subscription in + guard let self else { return } + if self.currentSubscription != subscription { + self.currentSubscription = subscription + if let subscription { + self.syncWidgetSubscriptionStatus(subscription: subscription) + } + } } - } - } + .store(in: &cancellables) - /// Sync all subscription state from DataManager (Kotlin single source of truth) - @MainActor - private func syncFromDataManager() { - // Read subscription status from DataManager - if let subscription = ComposeApp.DataManager.shared.subscription.value as? SubscriptionStatus { - if self.currentSubscription == nil || self.currentSubscription != subscription { - self.currentSubscription = subscription - syncWidgetSubscriptionStatus(subscription: subscription) + DataManagerObservable.shared.$upgradeTriggers + .sink { [weak self] triggers in + self?.upgradeTriggers = triggers } - } + .store(in: &cancellables) - // Read upgrade triggers from DataManager - if let triggers = ComposeApp.DataManager.shared.upgradeTriggers.value as? [String: UpgradeTriggerData] { - self.upgradeTriggers = triggers - } + DataManagerObservable.shared.$featureBenefits + .sink { [weak self] benefits in + self?.featureBenefits = benefits + } + .store(in: &cancellables) - // Read feature benefits from DataManager - if let benefits = ComposeApp.DataManager.shared.featureBenefits.value as? [FeatureBenefit] { - self.featureBenefits = benefits - } - - // Read promotions from DataManager - if let promos = ComposeApp.DataManager.shared.promotions.value as? [Promotion] { - self.promotions = promos - } + DataManagerObservable.shared.$promotions + .sink { [weak self] promos in + self?.promotions = promos + } + .store(in: &cancellables) } func refreshFromCache() { - Task { @MainActor in - syncFromDataManager() + // Trigger a re-read from DataManager; the Combine subscriptions will + // propagate changes automatically when DataManagerObservable updates. + let subscription = ComposeApp.DataManager.shared.subscription.value as? SubscriptionStatus + if self.currentSubscription != subscription { + self.currentSubscription = subscription + if let subscription { + syncWidgetSubscriptionStatus(subscription: subscription) + } } } func updateSubscription(_ subscription: SubscriptionStatus) { // Write to DataManager (single source of truth) ComposeApp.DataManager.shared.setSubscription(subscription: subscription) - DispatchQueue.main.async { - self.currentSubscription = subscription - // Sync subscription status with widget - self.syncWidgetSubscriptionStatus(subscription: subscription) - } + // Update local state directly (@MainActor guarantees main thread) + self.currentSubscription = subscription + syncWidgetSubscriptionStatus(subscription: subscription) } /// Sync subscription status with widget extension @@ -177,11 +179,10 @@ class SubscriptionCacheWrapper: ObservableObject { ComposeApp.DataManager.shared.setUpgradeTriggers(triggers: [:]) ComposeApp.DataManager.shared.setFeatureBenefits(benefits: []) ComposeApp.DataManager.shared.setPromotions(promos: []) - DispatchQueue.main.async { - self.currentSubscription = nil - self.upgradeTriggers = [:] - self.featureBenefits = [] - self.promotions = [] - } + // Update local state directly (@MainActor guarantees main thread) + self.currentSubscription = nil + self.upgradeTriggers = [:] + self.featureBenefits = [] + self.promotions = [] } } diff --git a/iosApp/iosApp/Subscription/UpgradePromptView.swift b/iosApp/iosApp/Subscription/UpgradePromptView.swift index 9c000e4..f5ee2c0 100644 --- a/iosApp/iosApp/Subscription/UpgradePromptView.swift +++ b/iosApp/iosApp/Subscription/UpgradePromptView.swift @@ -6,9 +6,11 @@ import StoreKit struct PromoContentView: View { let content: String + private let lines: [PromoLine] - private var lines: [PromoLine] { - parseContent(content) + init(content: String) { + self.content = content + self.lines = Self.parseContent(content) } var body: some View { @@ -70,7 +72,7 @@ struct PromoContentView: View { case spacer } - private func parseContent(_ content: String) -> [PromoLine] { + private static func parseContent(_ content: String) -> [PromoLine] { var result: [PromoLine] = [] let lines = content.components(separatedBy: "\n") @@ -412,7 +414,7 @@ private struct OrganicSubscriptionButton: View { @Environment(\.colorScheme) var colorScheme var isAnnual: Bool { - product.id.contains("annual") + product.subscription?.subscriptionPeriod.unit == .year } var savingsText: String? { @@ -489,7 +491,7 @@ struct SubscriptionProductButton: View { let onSelect: () -> Void var isAnnual: Bool { - product.id.contains("annual") + product.subscription?.subscriptionPeriod.unit == .year } var savingsText: String? { diff --git a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift index e5bdd31..05ab601 100644 --- a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift +++ b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift @@ -141,10 +141,14 @@ struct PropertyHeaderCard: View { .naturalShadow(.pronounced) } - private func formatNumber(_ num: Int) -> String { + private static let decimalFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .decimal - return formatter.string(from: NSNumber(value: num)) ?? "\(num)" + return formatter + }() + + private func formatNumber(_ num: Int) -> String { + Self.decimalFormatter.string(from: NSNumber(value: num)) ?? "\(num)" } } diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 1aafcab..579f0df 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -40,7 +40,7 @@ struct AllTasksView: View { .sheet(isPresented: $showAddTask) { AddTaskWithResidenceView( isPresented: $showAddTask, - residences: residenceViewModel.myResidences?.residences.toResidences() ?? [] + residences: residenceViewModel.myResidences?.residences ?? [] ) } .sheet(isPresented: $showEditTask) { @@ -91,16 +91,6 @@ struct AllTasksView: View { } message: { Text(L10n.Tasks.cancelConfirm) } - .onChange(of: showAddTask) { isShowing in - if !isShowing { - loadAllTasks() - } - } - .onChange(of: showEditTask) { isShowing in - if !isShowing { - loadAllTasks() - } - } .onAppear { AnalyticsManager.shared.trackScreen(.tasks) @@ -257,7 +247,7 @@ struct AllTasksView: View { .rotationEffect(.degrees(isLoadingTasks ? 360 : 0)) .animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks) } - .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks) + .disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true) || isLoadingTasks) Button(action: { if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") { @@ -268,16 +258,11 @@ struct AllTasksView: View { }) { OrganicToolbarAddButton() } - .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) + .disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true)) .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) } } } - .onChange(of: taskViewModel.isLoading) { isLoading in - if !isLoading { - loadAllTasks() - } - } } private func loadAllTasks(forceRefresh: Bool = false) { @@ -313,6 +298,7 @@ private struct OrganicEmptyTasksView: View { @Binding var showingUpgradePrompt: Bool @Binding var showAddTask: Bool @State private var isAnimating = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: OrganicSpacing.comfortable) { @@ -335,7 +321,9 @@ private struct OrganicEmptyTasksView: View { .frame(width: 160, height: 160) .scaleEffect(isAnimating ? 1.1 : 1.0) .animation( - Animation.easeInOut(duration: 3).repeatForever(autoreverses: true), + isAnimating && !reduceMotion + ? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true) + : .default, value: isAnimating ) @@ -349,7 +337,9 @@ private struct OrganicEmptyTasksView: View { .foregroundColor(Color.appPrimary) .offset(y: isAnimating ? -2 : 2) .animation( - Animation.easeInOut(duration: 2).repeatForever(autoreverses: true), + isAnimating && !reduceMotion + ? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true) + : .default, value: isAnimating ) } @@ -421,6 +411,9 @@ private struct OrganicEmptyTasksView: View { .onAppear { isAnimating = true } + .onDisappear { + isAnimating = false + } } } @@ -463,13 +456,8 @@ struct RoundedCorner: Shape { } #Preview { - NavigationView { + NavigationStack { AllTasksView() } } -extension Array where Element == ResidenceResponse { - func toResidences() -> [ResidenceResponse] { - return self - } -} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index a1862e3..6859ceb 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -48,21 +48,11 @@ struct iOSApp: App { // Initialize PostHog Analytics (must use Swift AnalyticsManager, not the Kotlin stub) AnalyticsManager.shared.configure() } - - // Initialize lookups at app start (public endpoints, no auth required) - // This fetches /static_data/ and /upgrade-triggers/ immediately - if !UITestRuntime.isEnabled { - Task { - print("🚀 Initializing lookups at app start...") - _ = try? await APILayer.shared.initializeLookups() - print("✅ Lookups initialized") - } - } } var body: some Scene { WindowGroup { - RootView() + RootView(deepLinkResetToken: $deepLinkResetToken) .environmentObject(themeManager) .environmentObject(contractorSharingManager) .environmentObject(residenceSharingManager) @@ -202,7 +192,9 @@ struct iOSApp: App { /// Handles all incoming URLs - both deep links and file opens private func handleIncomingURL(url: URL) { + #if DEBUG print("URL received: \(url)") + #endif // Handle .casera file imports if url.pathExtension.lowercased() == "casera" { @@ -216,12 +208,16 @@ struct iOSApp: App { return } + #if DEBUG print("Unrecognized URL: \(url)") + #endif } /// Handles .casera file imports - detects type and routes accordingly private func handleCaseraFileImport(url: URL) { + #if DEBUG print("Casera file received: \(url)") + #endif // Check if user is authenticated guard TokenStorage.shared.getToken() != nil else { @@ -229,35 +225,38 @@ struct iOSApp: App { return } - // Read file and detect type + // Read file and detect type — copy to temp while security scope is active let accessing = url.startAccessingSecurityScopedResource() - defer { - if accessing { - url.stopAccessingSecurityScopedResource() - } - } do { let data = try Data(contentsOf: url) + + // Copy to temp location so import works after security scope ends + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("casera") + try data.write(to: tempURL) + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let typeString = json["type"] as? String { - // Route based on type - if typeString == "residence" { - pendingImportType = .residence - } else { - pendingImportType = .contractor - } + pendingImportType = typeString == "residence" ? .residence : .contractor } else { - // Default to contractor for backward compatibility (files without type field) pendingImportType = .contractor } + + pendingImportURL = tempURL } catch { + #if DEBUG print("Failed to read casera file: \(error)") + #endif pendingImportType = .contractor + pendingImportURL = url + } + + if accessing { + url.stopAccessingSecurityScopedResource() } - // Store URL and show confirmation dialog - pendingImportURL = url showImportConfirmation = true } @@ -265,7 +264,9 @@ struct iOSApp: App { private func handleDeepLink(url: URL) { // Handle casera://reset-password?token=xxx guard url.host == "reset-password" else { + #if DEBUG print("Unrecognized deep link host: \(url.host ?? "nil")") + #endif return } @@ -273,10 +274,14 @@ struct iOSApp: App { if let components = URLComponents(url: url, resolvingAgainstBaseURL: true), let queryItems = components.queryItems, let token = queryItems.first(where: { $0.name == "token" })?.value { + #if DEBUG print("Reset token extracted: \(token)") + #endif deepLinkResetToken = token } else { + #if DEBUG print("No token found in deep link") + #endif } } }