Harden iOS app: fix concurrency, animations, formatters, privacy, and logging

- Eliminate NumberFormatters shared singleton data race; use local formatters
- Add reduceMotion checks to empty-state animations in 3 list views
- Wrap 68+ print() statements in #if DEBUG across push notification code
- Remove redundant .receive(on: DispatchQueue.main) in SubscriptionCache
- Remove redundant initializeLookups() call from iOSApp.init()
- Clean up StoreKitManager Task capture in listenForTransactions()
- Add memory warning observer to AuthenticatedImage cache
- Cache parseContent result in UpgradePromptView init
- Add DiskSpace and FileTimestamp API declarations to Privacy Manifest
- Add FIXME for analytics debug/production API key separation
- Use static formatter in PropertyHeaderCard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-04 23:15:42 -06:00
parent bf5d60ca63
commit c5f2bee83f
22 changed files with 683 additions and 347 deletions

View File

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

View File

@@ -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<NSString, UIImage>()
private static var imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
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

View File

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

View File

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

View File

@@ -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<Void, Never>?
///
/// 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<Void, Never> {
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<Void, Never> {
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<Void, Never> {
return observe(
stateFlow,
onLoading: {
loadingSetter(true)

View File

@@ -81,6 +81,7 @@ class DataManagerObservable: ObservableObject {
// MARK: - Private Properties
private var observationTasks: [Task<Void, Never>] = []
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() }

View File

@@ -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..<dotCount {
let x = CGFloat.random(in: 0...w)
let y = CGFloat.random(in: 0...h)
let grainOpacity = CGFloat.random(in: 0.3...1.0)
cgCtx.setFillColor(UIColor.black.withAlphaComponent(grainOpacity).cgColor)
cgCtx.fillEllipse(in: CGRect(x: x, y: y, width: 1, height: 1))
}
}
}
}
// MARK: - Grain Texture Overlay
struct GrainTexture: View {
var opacity: Double = 0.03
var animated: Bool = false
@State private var phase: Double = 0
var body: some View {
GeometryReader { geometry in
Canvas { context, size in
for _ in 0..<Int(size.width * size.height / 50) {
let x = CGFloat.random(in: 0...size.width)
let y = CGFloat.random(in: 0...size.height)
let grainOpacity = Double.random(in: 0.3...1.0)
context.fill(
Path(ellipseIn: CGRect(x: x, y: y, width: 1, height: 1)),
with: .color(.black.opacity(grainOpacity * opacity))
)
}
}
if let uiImage = GrainTextureCache.shared.cachedImage {
Image(uiImage: uiImage)
.resizable(resizingMode: .tile)
.opacity(opacity)
.allowsHitTesting(false)
}
.allowsHitTesting(false)
}
}
@@ -393,6 +424,8 @@ struct OrganicSpacing {
struct FloatingLeaf: View {
@State private var rotation: Double = 0
@State private var offset: CGFloat = 0
@State private var isAnimating = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var delay: Double = 0
var size: CGFloat = 20
var color: Color = Color.appPrimary
@@ -404,6 +437,8 @@ struct FloatingLeaf: View {
.rotationEffect(.degrees(rotation))
.offset(y: offset)
.onAppear {
isAnimating = true
guard !reduceMotion else { return }
withAnimation(
Animation
.easeInOut(duration: 4)
@@ -414,6 +449,11 @@ struct FloatingLeaf: View {
offset = 8
}
}
.onDisappear {
isAnimating = false
rotation = 0
offset = 0
}
}
}

View File

@@ -61,7 +61,15 @@ enum ThemeID: String, CaseIterable, Codable {
// MARK: - Shared App Group UserDefaults
private let appGroupID = "group.com.tt.casera.CaseraDev"
private let sharedDefaults = UserDefaults(suiteName: appGroupID) ?? UserDefaults.standard
private let sharedDefaults: UserDefaults = {
guard let defaults = UserDefaults(suiteName: appGroupID) else {
#if DEBUG
assertionFailure("App Group '\(appGroupID)' not configured — theme won't sync to widgets")
#endif
return UserDefaults.standard
}
return defaults
}()
// MARK: - Theme Manager
#if WIDGET_EXTENSION
@@ -84,6 +92,7 @@ class ThemeManager {
}
#else
// Full ThemeManager for main app with ObservableObject support
@MainActor
class ThemeManager: ObservableObject {
static let shared = ThemeManager()

View File

@@ -83,8 +83,10 @@ class LoginViewModel: ObservableObject {
self.isVerified = response.user.verified
self.isLoading = false
#if DEBUG
print("Login successful!")
print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)")
#endif
// Share token and API URL with widget extension
WidgetDataManager.shared.saveAuthToken(response.token)
@@ -93,10 +95,8 @@ class LoginViewModel: ObservableObject {
// Track successful login
AnalyticsManager.shared.track(.userSignedIn(method: "email"))
// Initialize lookups via APILayer
Task {
_ = try? await APILayer.shared.initializeLookups()
}
// Lookups are already initialized by APILayer.login() internally
// (see APILayer.kt line 1205) no need to call again here
// Call login success callback
self.onLoginSuccess?(self.isVerified)
@@ -138,13 +138,21 @@ class LoginViewModel: ObservableObject {
} else {
self.errorMessage = ErrorMessageParser.parse(error.message)
}
#if DEBUG
print("API Error: \(error.message)")
#endif
}
func logout() {
Task {
// APILayer.logout clears DataManager
try? await APILayer.shared.logout()
do {
_ = try await APILayer.shared.logout()
} catch {
#if DEBUG
print("Logout error: \(error)")
#endif
}
SubscriptionCacheWrapper.shared.clear()
PushNotificationManager.shared.clearRegistrationCache()
@@ -153,6 +161,9 @@ class LoginViewModel: ObservableObject {
WidgetDataManager.shared.clearCache()
WidgetDataManager.shared.clearAuthToken()
// Clear authenticated image cache
AuthenticatedImage.clearCache()
// Reset local state
self.isVerified = false
self.currentUser = nil
@@ -160,7 +171,9 @@ class LoginViewModel: ObservableObject {
self.password = ""
self.errorMessage = nil
#if DEBUG
print("Logged out - all state reset")
#endif
}
}

View File

@@ -9,22 +9,35 @@ enum OnboardingIntent: String {
}
/// Manages the state of the onboarding flow
@MainActor
class OnboardingState: ObservableObject {
static let shared = OnboardingState()
/// Whether the user has completed onboarding
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false
// MARK: - Persisted State (via @AppStorage, survives app restarts)
/// Whether the user has completed onboarding.
/// This is the persisted flag read at launch to decide whether to show onboarding.
/// When set to `true`, Kotlin DataManager is also updated to keep both layers in sync.
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false {
didSet {
// Keep Kotlin DataManager in sync so Android/shared code sees the same value.
ComposeApp.DataManager.shared.setHasCompletedOnboarding(completed: hasCompletedOnboarding)
}
}
/// The name of the residence being created during onboarding
@AppStorage("onboardingResidenceName") var pendingResidenceName: String = ""
/// Backing storage for user intent (persisted across app restarts)
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
// MARK: - Transient State (via @Published, reset each session as needed)
/// The ID of the residence created during onboarding (used for task creation)
@Published var createdResidenceId: Int32? = nil
/// The user's selected intent (start fresh or join existing) - persisted
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
/// The user's selected intent (start fresh or join existing)
/// The user's selected intent (start fresh or join existing).
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
var userIntent: OnboardingIntent {
get { OnboardingIntent(rawValue: userIntentRaw) ?? .unknown }
set {
@@ -84,7 +97,8 @@ class OnboardingState: ObservableObject {
currentStep = step
}
/// Complete the onboarding flow
/// Complete the onboarding flow.
/// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`.
func completeOnboarding() {
hasCompletedOnboarding = true
isOnboardingActive = false
@@ -93,7 +107,8 @@ class OnboardingState: ObservableObject {
userIntent = .unknown
}
/// Reset onboarding state (useful for testing or re-onboarding)
/// Reset onboarding state (useful for testing or re-onboarding).
/// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`.
func reset() {
hasCompletedOnboarding = false
isOnboardingActive = false

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

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

View File

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

View File

@@ -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<DeviceRegistrationResponse> {
#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<TaskCompletionResponse> {
#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<TaskResponse> {
#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<TaskResponse> {
#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<TaskResponse> {
#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<MessageResponse> {
#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<NotificationPreference> {
#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<NotificationPreference> {
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
}
}

View File

@@ -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<Bool> {
Binding<Bool>(
get: { !self.wrappedValue },
set: { self.wrappedValue = !$0 }
)
}
}

View File

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

View File

@@ -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<ComposeApp.SubscriptionStatus>,
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<String> = []
@@ -232,22 +231,20 @@ class StoreKitManager: ObservableObject {
/// Listen for transaction updates
private func listenForTransactions() -> Task<Void, Error> {
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<VerificationResponse>,
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<VerificationResponse>,
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<ComposeApp.SubscriptionStatus>,
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<VerificationResponse>,
let response = successResult.data,
!response.success {
print("❌ StoreKit: Backend verification failed: \(response.message)")
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
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<VerificationResponse>,
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)"
}
}
}

View File

@@ -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<AnyCancellable>()
/// 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 = []
}
}

View File

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

View File

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

View File

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

View File

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