Files
honeyDueKMP/iosApp/iosApp/Subscription/SubscriptionCache.swift
Trey t 9c574c4343 Harden iOS app with audit fixes, UI consistency, and sheet race condition fixes
Applies verified fixes from deep audit (concurrency, performance, security,
accessibility), standardizes CRUD form buttons to Add/Save pattern, removes
.drawingGroup() that broke search bar TextFields, and converts vulnerable
.sheet(isPresented:) + if-let patterns to safe presentation to prevent
blank white modals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:59:56 -06:00

205 lines
8.1 KiB
Swift

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 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()
@Published var currentSubscription: SubscriptionStatus?
@Published var upgradeTriggers: [String: UpgradeTriggerData] = [:]
@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 {
// Active trial grants pro access.
if let subscription = currentSubscription, subscription.trialActive {
return "pro"
}
// Prefer backend subscription state when available.
// `expiresAt` is only expected for active paid plans.
if let subscription = currentSubscription,
let expiresAt = subscription.expiresAt,
!expiresAt.isEmpty {
// Parse the date and check if subscription is still active
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let expiryDate = formatter.date(from: expiresAt) ?? ISO8601DateFormatter().date(from: expiresAt) {
if expiryDate > Date() {
return "pro"
}
// Expired fall through to StoreKit check
} else {
// Can't parse date but backend says there's a subscription
return "pro"
}
}
// Fallback to local StoreKit entitlements.
return StoreKitManager.shared.purchasedProductIDs.isEmpty ? "free" : "pro"
}
/// Check if user should be blocked from adding an item based on LIVE count
/// - Parameters:
/// - currentCount: The actual current count from the data (e.g., viewModel.residences.count)
/// - limitKey: The key to check ("properties", "tasks", "contractors", or "documents")
/// - Returns: true if should show upgrade prompt (blocked), false if allowed
func shouldShowUpgradePrompt(currentCount: Int, limitKey: String) -> Bool {
// If limitations are disabled globally, never block
guard let subscription = currentSubscription, subscription.limitationsEnabled else {
return false
}
// Pro tier never gets blocked
if currentTier == "pro" {
return false
}
// Get the appropriate limits for the current tier
guard let tierLimits = subscription.limits[currentTier] else {
print("No limits found for tier: \(currentTier)")
return false
}
// Get the specific limit for this resource type
let limit: Int?
switch limitKey {
case "properties":
limit = tierLimits.properties.map { Int(truncating: $0) }
case "tasks":
limit = tierLimits.tasks.map { Int(truncating: $0) }
case "contractors":
limit = tierLimits.contractors.map { Int(truncating: $0) }
case "documents":
limit = tierLimits.documents.map { Int(truncating: $0) }
default:
print("Unknown limit key: \(limitKey)")
return false
}
// nil limit means unlimited
guard let actualLimit = limit else {
return false
}
// Block if current count >= actualLimit
return currentCount >= Int(actualLimit)
}
/// Deprecated: Use shouldShowUpgradePrompt(currentCount:limitKey:) instead
var shouldShowUpgradePrompt: Bool {
currentTier == "free" && (currentSubscription?.limitationsEnabled ?? false)
}
/// Check if user can share residences (Pro feature)
/// - Returns: true if allowed, false if should show upgrade prompt
func canShareResidence() -> Bool {
// If limitations are disabled globally, allow
guard let subscription = currentSubscription, subscription.limitationsEnabled else {
return true
}
// Pro tier can share
return currentTier == "pro"
}
/// Check if user can share contractors (Pro feature)
/// - Returns: true if allowed, false if should show upgrade prompt
func canShareContractor() -> Bool {
// If limitations are disabled globally, allow
guard let subscription = currentSubscription, subscription.limitationsEnabled else {
return true
}
// Pro tier can share
return currentTier == "pro"
}
private init() {
// Observe only subscription-related @Published properties from DataManagerObservable
// instead of the broad objectWillChange (which fires on ALL 25+ property changes).
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)
DataManagerObservable.shared.$upgradeTriggers
.sink { [weak self] triggers in
self?.upgradeTriggers = triggers
}
.store(in: &cancellables)
DataManagerObservable.shared.$featureBenefits
.sink { [weak self] benefits in
self?.featureBenefits = benefits
}
.store(in: &cancellables)
DataManagerObservable.shared.$promotions
.sink { [weak self] promos in
self?.promotions = promos
}
.store(in: &cancellables)
}
func refreshFromCache() {
// 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)
// Update local state directly (@MainActor guarantees main thread)
self.currentSubscription = subscription
syncWidgetSubscriptionStatus(subscription: subscription)
}
/// Sync subscription status with widget extension
private func syncWidgetSubscriptionStatus(subscription: SubscriptionStatus) {
let limitationsEnabled = subscription.limitationsEnabled
let isPremium = currentTier == "pro"
WidgetDataManager.shared.saveSubscriptionStatus(
limitationsEnabled: limitationsEnabled,
isPremium: isPremium
)
}
func clear() {
// Clear via DataManager (single source of truth)
ComposeApp.DataManager.shared.setSubscription(subscription: nil)
ComposeApp.DataManager.shared.setUpgradeTriggers(triggers: [:])
ComposeApp.DataManager.shared.setFeatureBenefits(benefits: [])
ComposeApp.DataManager.shared.setPromotions(promos: [])
// Update local state directly (@MainActor guarantees main thread)
self.currentSubscription = nil
self.upgradeTriggers = [:]
self.featureBenefits = []
self.promotions = []
}
}