- Completion animations: play user-selected animation on task card after completing, with DataManager guard to prevent race condition during animation playback. Works in both AllTasksView and ResidenceDetailView. Animation preference persisted via @AppStorage and configurable from Settings. - Subscription: add trial fields (trialStart, trialEnd, trialActive) and subscriptionSource to model, cross-platform purchase guard, trial banner in upgrade prompt, and platform-aware subscription management in profile. - Analytics: disable PostHog SDK debug logging and remove console print statements to reduce debug console noise. - Documents: remove redundant nested do-catch blocks in ViewModel wrapper. - Widgets: add debounced timeline reloads and thread-safe file I/O queue. - Onboarding: fix animation leak on disappear, remove unused state vars. - Remove unused files (ContentView, StateFlowExtensions, CustomView). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
394 lines
12 KiB
Kotlin
394 lines
12 KiB
Kotlin
package com.example.casera.utils
|
|
|
|
import com.example.casera.data.DataManager
|
|
|
|
/**
|
|
* Canonical product IDs for in-app subscriptions.
|
|
*
|
|
* These must match the product IDs configured in:
|
|
* - Apple App Store Connect (StoreKit configuration)
|
|
* - Google Play Console (subscription products)
|
|
*
|
|
* All code referencing subscription product IDs should use these constants
|
|
* instead of hardcoded strings to ensure consistency.
|
|
*/
|
|
object SubscriptionProducts {
|
|
const val MONTHLY = "com.example.casera.pro.monthly"
|
|
const val ANNUAL = "com.example.casera.pro.annual"
|
|
|
|
/** All product IDs as a list, useful for querying store product details. */
|
|
val all: List<String> = listOf(MONTHLY, ANNUAL)
|
|
}
|
|
|
|
/**
|
|
* Helper for checking subscription limits and determining when to show upgrade prompts.
|
|
*
|
|
* Reads ALL subscription state from DataManager (single source of truth).
|
|
* The current tier is derived from the backend subscription status:
|
|
* - If expiresAt is present and non-empty, user is "pro"
|
|
* - Otherwise, user is "free"
|
|
*
|
|
* RULES:
|
|
* 1. Backend limitations OFF: Never show upgrade view, allow everything
|
|
* 2. Backend limitations ON + limit=0: Show upgrade view, block access entirely (no add button)
|
|
* 3. Backend limitations ON + limit>0: Allow access with add button, show upgrade when limit reached
|
|
*
|
|
* These rules apply to: residence, task, contractors, documents
|
|
*/
|
|
object SubscriptionHelper {
|
|
/**
|
|
* Result of a usage/access check
|
|
* @param allowed Whether the action is allowed
|
|
* @param triggerKey The upgrade trigger key to use if not allowed (null if allowed)
|
|
*/
|
|
data class UsageCheck(val allowed: Boolean, val triggerKey: String?)
|
|
|
|
/**
|
|
* Derive the current subscription tier from DataManager.
|
|
* "pro" if the backend subscription has an active trial or a non-empty expiresAt (active paid plan),
|
|
* "free" otherwise.
|
|
*/
|
|
val currentTier: String
|
|
get() {
|
|
val subscription = DataManager.subscription.value ?: return "free"
|
|
if (subscription.trialActive) return "pro"
|
|
val expiresAt = subscription.expiresAt
|
|
return if (!expiresAt.isNullOrEmpty()) "pro" else "free"
|
|
}
|
|
|
|
/**
|
|
* Whether the user has a premium (pro) subscription.
|
|
* True when limitations are disabled (everyone gets full access)
|
|
* OR when the user is on the pro tier.
|
|
*/
|
|
val isPremium: Boolean
|
|
get() {
|
|
val subscription = DataManager.subscription.value ?: return false
|
|
// If limitations are disabled, everyone effectively has premium access
|
|
if (!subscription.limitationsEnabled) return true
|
|
return currentTier == "pro"
|
|
}
|
|
|
|
/**
|
|
* Check if the user can purchase a subscription on the given platform.
|
|
* If the user already has an active Pro subscription from a different platform,
|
|
* purchasing on this platform is not allowed.
|
|
*
|
|
* @param currentPlatform The platform attempting the purchase ("ios", "android", or "stripe")
|
|
* @return UsageCheck with allowed=false if already subscribed on another platform
|
|
*/
|
|
fun canPurchaseOnPlatform(currentPlatform: String): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(allowed = true, triggerKey = null)
|
|
if (currentTier != "pro") return UsageCheck(allowed = true, triggerKey = null)
|
|
val source = subscription.subscriptionSource
|
|
if (source != null && source != currentPlatform) {
|
|
return UsageCheck(allowed = false, triggerKey = "already_subscribed_other_platform")
|
|
}
|
|
return UsageCheck(allowed = true, triggerKey = null)
|
|
}
|
|
|
|
// ===== PROPERTY (RESIDENCE) =====
|
|
|
|
/**
|
|
* Check if the user should see an upgrade view instead of the residences screen.
|
|
* Returns true (blocked) only when limitations are ON and limit=0.
|
|
*/
|
|
fun isResidencesBlocked(): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(false, null) // Allow access while loading
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(false, null) // Limitations disabled, never block
|
|
}
|
|
|
|
val tier = currentTier
|
|
if (tier == "pro") {
|
|
return UsageCheck(false, null) // Pro users never blocked
|
|
}
|
|
|
|
val limit = subscription.limits[tier]?.properties
|
|
|
|
// If limit is 0, block access entirely
|
|
if (limit == 0) {
|
|
return UsageCheck(true, "add_second_property")
|
|
}
|
|
|
|
return UsageCheck(false, null) // limit > 0 or unlimited, allow access
|
|
}
|
|
|
|
/**
|
|
* Check if user can add a property (when trying to add, not for blocking the screen).
|
|
* Used when limit > 0 and user has reached the limit.
|
|
*/
|
|
fun canAddProperty(currentCount: Int = 0): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(true, null) // Allow if no subscription data
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(true, null) // Limitations disabled, allow everything
|
|
}
|
|
|
|
val tier = currentTier
|
|
if (tier == "pro") {
|
|
return UsageCheck(true, null) // Pro tier gets unlimited access
|
|
}
|
|
|
|
// Get limit for current tier (null = unlimited)
|
|
val limit = subscription.limits[tier]?.properties
|
|
|
|
// null means unlimited
|
|
if (limit == null) {
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
// If limit is 0, they shouldn't even be here (screen should be blocked)
|
|
// But if they somehow are, block the add
|
|
if (limit == 0) {
|
|
return UsageCheck(false, "add_second_property")
|
|
}
|
|
|
|
// limit > 0: check if they've reached it
|
|
if (currentCount >= limit) {
|
|
return UsageCheck(false, "add_second_property")
|
|
}
|
|
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
// ===== TASKS =====
|
|
|
|
/**
|
|
* Check if the user should see an upgrade view instead of the tasks screen.
|
|
* Returns true (blocked) only when limitations are ON and limit=0.
|
|
*/
|
|
fun isTasksBlocked(): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(false, null) // Allow access while loading
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(false, null)
|
|
}
|
|
|
|
val tier = currentTier
|
|
if (tier == "pro") {
|
|
return UsageCheck(false, null)
|
|
}
|
|
|
|
val limit = subscription.limits[tier]?.tasks
|
|
|
|
if (limit == 0) {
|
|
return UsageCheck(true, "add_11th_task")
|
|
}
|
|
|
|
return UsageCheck(false, null)
|
|
}
|
|
|
|
/**
|
|
* Check if user can add a task (when trying to add).
|
|
*/
|
|
fun canAddTask(currentCount: Int = 0): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(true, null)
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
val tier = currentTier
|
|
if (tier == "pro") {
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
val limit = subscription.limits[tier]?.tasks
|
|
|
|
if (limit == null) {
|
|
return UsageCheck(true, null) // Unlimited
|
|
}
|
|
|
|
if (limit == 0) {
|
|
return UsageCheck(false, "add_11th_task")
|
|
}
|
|
|
|
if (currentCount >= limit) {
|
|
return UsageCheck(false, "add_11th_task")
|
|
}
|
|
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
// ===== CONTRACTORS =====
|
|
|
|
/**
|
|
* Check if the user should see an upgrade view instead of the contractors screen.
|
|
* Returns true (blocked) only when limitations are ON and limit=0.
|
|
*/
|
|
fun isContractorsBlocked(): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(false, null) // Allow access while loading
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(false, null)
|
|
}
|
|
|
|
val tier = currentTier
|
|
if (tier == "pro") {
|
|
return UsageCheck(false, null)
|
|
}
|
|
|
|
val limit = subscription.limits[tier]?.contractors
|
|
|
|
if (limit == 0) {
|
|
return UsageCheck(true, "view_contractors")
|
|
}
|
|
|
|
return UsageCheck(false, null)
|
|
}
|
|
|
|
/**
|
|
* Check if user can add a contractor (when trying to add).
|
|
*/
|
|
fun canAddContractor(currentCount: Int = 0): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(true, null)
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
val tier = currentTier
|
|
if (tier == "pro") {
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
val limit = subscription.limits[tier]?.contractors
|
|
|
|
if (limit == null) {
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
if (limit == 0) {
|
|
return UsageCheck(false, "view_contractors")
|
|
}
|
|
|
|
if (currentCount >= limit) {
|
|
return UsageCheck(false, "view_contractors")
|
|
}
|
|
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
// ===== DOCUMENTS =====
|
|
|
|
/**
|
|
* Check if the user should see an upgrade view instead of the documents screen.
|
|
* Returns true (blocked) only when limitations are ON and limit=0.
|
|
*/
|
|
fun isDocumentsBlocked(): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(false, null) // Allow access while loading
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(false, null)
|
|
}
|
|
|
|
val tier = currentTier
|
|
if (tier == "pro") {
|
|
return UsageCheck(false, null)
|
|
}
|
|
|
|
val limit = subscription.limits[tier]?.documents
|
|
|
|
if (limit == 0) {
|
|
return UsageCheck(true, "view_documents")
|
|
}
|
|
|
|
return UsageCheck(false, null)
|
|
}
|
|
|
|
/**
|
|
* Check if user can add a document (when trying to add).
|
|
*/
|
|
fun canAddDocument(currentCount: Int = 0): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(true, null)
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
val tier = currentTier
|
|
if (tier == "pro") {
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
val limit = subscription.limits[tier]?.documents
|
|
|
|
if (limit == null) {
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
if (limit == 0) {
|
|
return UsageCheck(false, "view_documents")
|
|
}
|
|
|
|
if (currentCount >= limit) {
|
|
return UsageCheck(false, "view_documents")
|
|
}
|
|
|
|
return UsageCheck(true, null)
|
|
}
|
|
|
|
// ===== RESIDENCE SHARING =====
|
|
|
|
/**
|
|
* Check if user can share a residence (Pro feature).
|
|
* Returns true (blocked) when limitations are ON and user is not pro.
|
|
*/
|
|
fun canShareResidence(): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(true, null) // Allow if no subscription data (fallback)
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(true, null) // Limitations disabled, allow everything
|
|
}
|
|
|
|
if (currentTier == "pro") {
|
|
return UsageCheck(true, null) // Pro tier can share
|
|
}
|
|
|
|
// Free users cannot share residences
|
|
return UsageCheck(false, "share_residence")
|
|
}
|
|
|
|
// ===== CONTRACTOR SHARING =====
|
|
|
|
/**
|
|
* Check if user can share a contractor (Pro feature).
|
|
* Returns true (blocked) when limitations are ON and user is not pro.
|
|
*/
|
|
fun canShareContractor(): UsageCheck {
|
|
val subscription = DataManager.subscription.value
|
|
?: return UsageCheck(true, null) // Allow if no subscription data (fallback)
|
|
|
|
if (!subscription.limitationsEnabled) {
|
|
return UsageCheck(true, null) // Limitations disabled, allow everything
|
|
}
|
|
|
|
if (currentTier == "pro") {
|
|
return UsageCheck(true, null) // Pro tier can share
|
|
}
|
|
|
|
// Free users cannot share contractors
|
|
return UsageCheck(false, "share_contractor")
|
|
}
|
|
|
|
// ===== DEPRECATED - Keep for backwards compatibility =====
|
|
|
|
@Deprecated("Use isContractorsBlocked() instead", ReplaceWith("isContractorsBlocked()"))
|
|
fun shouldShowUpgradePromptForContractors(): UsageCheck = isContractorsBlocked()
|
|
|
|
@Deprecated("Use isDocumentsBlocked() instead", ReplaceWith("isDocumentsBlocked()"))
|
|
fun shouldShowUpgradePromptForDocuments(): UsageCheck = isDocumentsBlocked()
|
|
}
|