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>
205 lines
8.1 KiB
Swift
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 = []
|
|
}
|
|
}
|