Optimize subscription tier management and empty state logic

Changes:
- Make currentTier a computed property from StoreKit instead of stored value
- Initialize StoreKit at app launch and refresh on foreground for ready subscription status
- Fix empty state logic: show empty state when limit > 0, upgrade prompt when limit = 0
- Add subscription status display in Profile screen (iOS/Android)
- Add upgrade prompts to Residences and Tasks screens for free tier users
- Improve SubscriptionHelper with better tier checking logic

iOS:
- SubscriptionCache: currentTier now computed from StoreKit.purchasedProductIDs
- StoreKitManager: add refreshSubscriptionStatus() method
- AppDelegate: initialize StoreKit at launch and refresh on app active
- ContractorsListView: use shouldShowUpgradePrompt(currentCount:limitKey:)
- WarrantiesTabContent/DocumentsTabContent: same empty state fix
- ProfileTabView: display current tier and limitations status
- ResidencesListView/ResidenceDetailView: add upgrade prompts for free users
- AllTasksView: add upgrade prompt for free users

Android:
- ContractorsScreen/DocumentsScreen: fix empty state logic
- ProfileScreen: display subscription status and limits
- ResidencesScreen/ResidenceDetailScreen: add upgrade prompts
- UpgradeFeatureScreen: improve UI layout

Shared:
- APILayer: add getSubscriptionStatus() method
- SubscriptionHelper: add hasAccessToFeature() utility
- Remove duplicate subscription checking logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-24 18:59:09 -06:00
parent d92a4fd4f1
commit ce1ca0f0ce
23 changed files with 728 additions and 120 deletions

View File

@@ -5,12 +5,11 @@ import kotlinx.serialization.Serializable
@Serializable
data class SubscriptionStatus(
val tier: String, // "free" or "pro"
@SerialName("subscribed_at") val subscribedAt: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("auto_renew") val autoRenew: Boolean = true,
val usage: UsageStats,
val limits: TierLimits,
val limits: Map<String, TierLimits>, // {"free": {...}, "pro": {...}}
@SerialName("limitations_enabled") val limitationsEnabled: Boolean = false // Master toggle
)

View File

@@ -2,6 +2,7 @@ package com.example.mycrib.network
import com.example.mycrib.cache.DataCache
import com.example.mycrib.cache.DataPrefetchManager
import com.example.mycrib.cache.SubscriptionCache
import com.example.mycrib.models.*
import com.example.mycrib.network.*
import com.example.mycrib.storage.TokenStorage
@@ -26,16 +27,49 @@ object APILayer {
private val authApi = AuthApi()
private val lookupsApi = LookupsApi()
private val notificationApi = NotificationApi()
private val subscriptionApi = SubscriptionApi()
private val prefetchManager = DataPrefetchManager.getInstance()
// ==================== Lookups Operations ====================
/**
* Refresh subscription status from backend to get updated limits and usage.
* Call this when app comes to foreground or when limits might have changed.
*/
suspend fun refreshSubscriptionStatus(): ApiResult<SubscriptionStatus> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
println("🔄 [APILayer] Force refreshing subscription status from backend...")
val result = subscriptionApi.getSubscriptionStatus(token)
when (result) {
is ApiResult.Success -> {
println("✅ [APILayer] Subscription status refreshed successfully")
println(" 📊 Limitations Enabled: ${result.data.limitationsEnabled}")
println(" 📊 Free Tier Limits - Properties: ${result.data.limits["free"]?.properties}, Tasks: ${result.data.limits["free"]?.tasks}, Contractors: ${result.data.limits["free"]?.contractors}, Documents: ${result.data.limits["free"]?.documents}")
println(" 📊 Pro Tier Limits - Properties: ${result.data.limits["pro"]?.properties}, Tasks: ${result.data.limits["pro"]?.tasks}, Contractors: ${result.data.limits["pro"]?.contractors}, Documents: ${result.data.limits["pro"]?.documents}")
println(" 📊 Usage - Properties: ${result.data.usage.propertiesCount}, Tasks: ${result.data.usage.tasksCount}, Contractors: ${result.data.usage.contractorsCount}, Documents: ${result.data.usage.documentsCount}")
SubscriptionCache.updateSubscriptionStatus(result.data)
}
is ApiResult.Error -> {
println("❌ [APILayer] Failed to refresh subscription status: ${result.message}")
}
else -> {}
}
return result
}
/**
* Initialize all lookup data. Should be called once after login.
* Loads all reference data (residence types, task categories, priorities, etc.) into cache.
*/
suspend fun initializeLookups(): ApiResult<Unit> {
if (DataCache.lookupsInitialized.value) {
// Lookups already initialized, but refresh subscription status
// since limits/usage may have changed on the backend
println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...")
refreshSubscriptionStatus()
return ApiResult.Success(Unit)
}
@@ -50,6 +84,17 @@ object APILayer {
val taskCategoriesResult = lookupsApi.getTaskCategories(token)
val contractorSpecialtiesResult = lookupsApi.getContractorSpecialties(token)
// Load subscription status to get limitationsEnabled, usage, and limits from backend
// Note: tier (free/pro) will be updated by StoreKit after receipt verification
println("🔄 Fetching subscription status...")
val subscriptionStatusResult = subscriptionApi.getSubscriptionStatus(token)
println("📦 Subscription status result: $subscriptionStatusResult")
// Load upgrade triggers (subscription status comes from StoreKit, not backend)
println("🔄 Fetching upgrade triggers...")
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token)
println("📦 Upgrade triggers result: $upgradeTriggersResult")
// Update cache with successful results
if (residenceTypesResult is ApiResult.Success) {
DataCache.updateResidenceTypes(residenceTypesResult.data)
@@ -70,6 +115,22 @@ object APILayer {
DataCache.updateContractorSpecialties(contractorSpecialtiesResult.data)
}
if (subscriptionStatusResult is ApiResult.Success) {
println("✅ Updating subscription cache with: ${subscriptionStatusResult.data}")
SubscriptionCache.updateSubscriptionStatus(subscriptionStatusResult.data)
println("✅ Subscription cache updated successfully")
} else if (subscriptionStatusResult is ApiResult.Error) {
println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}")
}
if (upgradeTriggersResult is ApiResult.Success) {
println("✅ Updating upgrade triggers cache with ${upgradeTriggersResult.data.size} triggers")
SubscriptionCache.updateUpgradeTriggers(upgradeTriggersResult.data)
println("✅ Upgrade triggers cache updated successfully")
} else if (upgradeTriggersResult is ApiResult.Error) {
println("❌ Failed to fetch upgrade triggers: ${upgradeTriggersResult.message}")
}
DataCache.markLookupsInitialized()
return ApiResult.Success(Unit)
} catch (e: Exception) {

View File

@@ -15,6 +15,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.mycrib.models.Document
import com.example.mycrib.network.ApiResult
import com.example.mycrib.cache.SubscriptionCache
import com.example.mycrib.ui.subscription.UpgradeFeatureScreen
import com.example.mycrib.utils.SubscriptionHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -22,8 +25,11 @@ fun DocumentsTabContent(
state: ApiResult<List<Document>>,
isWarrantyTab: Boolean,
onDocumentClick: (Int) -> Unit,
onRetry: () -> Unit
onRetry: () -> Unit,
onNavigateBack: () -> Unit = {}
) {
val shouldShowUpgradePrompt = SubscriptionHelper.shouldShowUpgradePromptForDocuments().allowed
var isRefreshing by remember { mutableStateOf(false) }
// Handle refresh state
@@ -44,10 +50,20 @@ fun DocumentsTabContent(
is ApiResult.Success -> {
val documents = state.data
if (documents.isEmpty()) {
EmptyState(
icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description,
message = if (isWarrantyTab) "No warranties found" else "No documents found"
)
if (shouldShowUpgradePrompt) {
// Free tier users see upgrade prompt
UpgradeFeatureScreen(
triggerKey = "view_documents",
icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description,
onNavigateBack = onNavigateBack
)
} else {
// Pro users see empty state
EmptyState(
icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description,
message = if (isWarrantyTab) "No warranties found" else "No documents found"
)
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing,

View File

@@ -27,6 +27,9 @@ import com.example.mycrib.viewmodel.ContractorViewModel
import com.example.mycrib.models.ContractorSummary
import com.example.mycrib.network.ApiResult
import com.example.mycrib.repository.LookupsRepository
import com.example.mycrib.cache.SubscriptionCache
import com.example.mycrib.ui.subscription.UpgradeFeatureScreen
import com.example.mycrib.utils.SubscriptionHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -39,6 +42,7 @@ fun ContractorsScreen(
val deleteState by viewModel.deleteState.collectAsState()
val toggleFavoriteState by viewModel.toggleFavoriteState.collectAsState()
val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState()
val shouldShowUpgradePrompt = SubscriptionHelper.shouldShowUpgradePromptForContractors().allowed
var showAddDialog by remember { mutableStateOf(false) }
var selectedFilter by remember { mutableStateOf<String?>(null) }
@@ -249,33 +253,43 @@ fun ContractorsScreen(
val contractors = state
if (contractors.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
if (shouldShowUpgradePrompt) {
// Free tier users see upgrade prompt
UpgradeFeatureScreen(
triggerKey = "view_contractors",
icon = Icons.Default.People,
onNavigateBack = onNavigateBack
)
} else {
// Pro users see empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly)
"No contractors found"
else
"No contractors yet",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) {
Text(
"Add your first contractor to get started",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly)
"No contractors found"
else
"No contractors yet",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) {
Text(
"Add your first contractor to get started",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}

View File

@@ -189,7 +189,8 @@ fun DocumentsScreen(
category = selectedCategory,
isActive = if (showActiveOnly) true else null
)
}
},
onNavigateBack = onNavigateBack
)
}
DocumentTab.DOCUMENTS -> {
@@ -202,7 +203,8 @@ fun DocumentsScreen(
residenceId = residenceId,
documentType = selectedDocType
)
}
},
onNavigateBack = onNavigateBack
)
}
}

View File

@@ -18,12 +18,16 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.mycrib.ui.components.HandleErrors
import com.example.mycrib.ui.components.common.ErrorCard
import com.example.mycrib.ui.components.dialogs.ThemePickerDialog
import com.example.mycrib.utils.SubscriptionHelper
import com.example.mycrib.ui.theme.AppRadius
import com.example.mycrib.ui.theme.AppSpacing
import com.example.mycrib.ui.theme.ThemeManager
import com.example.mycrib.viewmodel.AuthViewModel
import com.example.mycrib.network.ApiResult
import com.example.mycrib.storage.TokenStorage
import com.example.mycrib.cache.SubscriptionCache
import com.example.mycrib.ui.subscription.UpgradePromptDialog
import androidx.compose.runtime.getValue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -40,9 +44,11 @@ fun ProfileScreen(
var successMessage by remember { mutableStateOf("") }
var isLoadingUser by remember { mutableStateOf(true) }
var showThemePicker by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) }
val updateState by viewModel.updateProfileState.collectAsState()
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
val currentSubscription by SubscriptionCache.currentSubscription
// Handle errors for profile update
updateState.HandleErrors(
@@ -202,7 +208,82 @@ fun ProfileScreen(
}
}
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
// Subscription Section - Only show if limitations are enabled
if (currentSubscription?.limitationsEnabled == true) {
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = "Subscription",
tint = if (SubscriptionHelper.currentTier == "pro") MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant
)
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
Text(
text = if (SubscriptionHelper.currentTier == "pro") "Pro Plan" else "Free Plan",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (SubscriptionHelper.currentTier == "pro" && currentSubscription?.expiresAt != null) {
"Active until ${currentSubscription?.expiresAt}"
} else {
"Limited features"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (SubscriptionHelper.currentTier != "pro") {
Button(
onClick = { showUpgradePrompt = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = null
)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text("Upgrade to Pro", fontWeight = FontWeight.SemiBold)
}
} else {
Text(
text = "Manage your subscription in the Google Play Store",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = AppSpacing.xs)
)
}
}
}
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
}
Text(
"Profile Information",
@@ -360,5 +441,18 @@ fun ProfileScreen(
onDismiss = { showThemePicker = false }
)
}
// Upgrade Prompt Dialog
if (showUpgradePrompt) {
UpgradePromptDialog(
triggerKey = "profile_upgrade",
onUpgrade = {
// Handle upgrade action - on Android this would open Play Store
// For now, just close the dialog
showUpgradePrompt = false
},
onDismiss = { showUpgradePrompt = false }
)
}
}
}

View File

@@ -30,6 +30,9 @@ import com.example.mycrib.viewmodel.TaskViewModel
import com.example.mycrib.models.Residence
import com.example.mycrib.models.TaskDetail
import com.example.mycrib.network.ApiResult
import com.example.mycrib.utils.SubscriptionHelper
import com.example.mycrib.ui.subscription.UpgradePromptDialog
import com.example.mycrib.cache.SubscriptionCache
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -63,6 +66,33 @@ fun ResidenceDetailScreen(
var taskToCancel by remember { mutableStateOf<TaskDetail?>(null) }
var taskToArchive by remember { mutableStateOf<TaskDetail?>(null) }
val deleteState by residenceViewModel.deleteResidenceState.collectAsState()
var showUpgradePrompt by remember { mutableStateOf(false) }
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
// Helper function to check LIVE task count against limits
fun canAddTask(): Pair<Boolean, String?> {
val subscription = SubscriptionCache.currentSubscription.value ?: return Pair(true, null)
// If limitations are disabled globally, allow everything
if (!subscription.limitationsEnabled) {
return Pair(true, null)
}
// Pro tier has no limits
if (SubscriptionHelper.currentTier == "pro") {
return Pair(true, null)
}
// Check LIVE count from current tasks state
val currentCount = (tasksState as? ApiResult.Success)?.data?.columns?.sumOf { it.tasks.size } ?: 0
val limit = subscription.limits[SubscriptionHelper.currentTier]?.tasks ?: 10
return if (currentCount >= limit) {
Pair(false, "add_11th_task")
} else {
Pair(true, null)
}
}
LaunchedEffect(residenceId) {
residenceViewModel.getResidence(residenceId) { result ->
@@ -184,6 +214,21 @@ fun ResidenceDetailScreen(
})
}
if (showUpgradePrompt && upgradeTriggerKey != null) {
UpgradePromptDialog(
triggerKey = upgradeTriggerKey!!,
onDismiss = {
showUpgradePrompt = false
upgradeTriggerKey = null
},
onUpgrade = {
// TODO: Navigate to subscription purchase screen
showUpgradePrompt = false
upgradeTriggerKey = null
}
)
}
if (showManageUsersDialog && residenceState is ApiResult.Success) {
val residence = (residenceState as ApiResult.Success<Residence>).data
ManageUsersDialog(
@@ -399,7 +444,15 @@ fun ResidenceDetailScreen(
},
floatingActionButton = {
FloatingActionButton(
onClick = { showNewTaskDialog = true },
onClick = {
val (allowed, triggerKey) = canAddTask()
if (allowed) {
showNewTaskDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
containerColor = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(16.dp)
) {

View File

@@ -26,6 +26,9 @@ import com.example.mycrib.ui.components.common.StatItem
import com.example.mycrib.ui.components.residence.TaskStatChip
import com.example.mycrib.viewmodel.ResidenceViewModel
import com.example.mycrib.network.ApiResult
import com.example.mycrib.utils.SubscriptionHelper
import com.example.mycrib.ui.subscription.UpgradePromptDialog
import com.example.mycrib.cache.SubscriptionCache
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -40,6 +43,33 @@ fun ResidencesScreen(
val myResidencesState by viewModel.myResidencesState.collectAsState()
var showJoinDialog by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) }
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
// Helper function to check LIVE property count against limits
fun canAddProperty(): Pair<Boolean, String?> {
val subscription = SubscriptionCache.currentSubscription.value ?: return Pair(true, null)
// If limitations are disabled globally, allow everything
if (!subscription.limitationsEnabled) {
return Pair(true, null)
}
// Pro tier has no limits
if (SubscriptionHelper.currentTier == "pro") {
return Pair(true, null)
}
// Check LIVE count from current state
val currentCount = (myResidencesState as? ApiResult.Success)?.data?.residences?.size ?: 0
val limit = subscription.limits[SubscriptionHelper.currentTier]?.properties ?: 1
return if (currentCount >= limit) {
Pair(false, "add_second_property")
} else {
Pair(true, null)
}
}
LaunchedEffect(Unit) {
viewModel.loadMyResidences()
@@ -71,6 +101,21 @@ fun ResidencesScreen(
)
}
if (showUpgradePrompt && upgradeTriggerKey != null) {
UpgradePromptDialog(
triggerKey = upgradeTriggerKey!!,
onDismiss = {
showUpgradePrompt = false
upgradeTriggerKey = null
},
onUpgrade = {
// TODO: Navigate to subscription purchase screen
showUpgradePrompt = false
upgradeTriggerKey = null
}
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -81,7 +126,15 @@ fun ResidencesScreen(
)
},
actions = {
IconButton(onClick = { showJoinDialog = true }) {
IconButton(onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
showJoinDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
}) {
Icon(Icons.Default.GroupAdd, contentDescription = "Join with Code")
}
IconButton(onClick = onNavigateToProfile) {
@@ -104,7 +157,15 @@ fun ResidencesScreen(
if (hasResidences) {
Box(modifier = Modifier.padding(bottom = 80.dp)) {
FloatingActionButton(
onClick = onAddResidence,
onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
onAddResidence()
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
containerColor = MaterialTheme.colorScheme.primary,
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 8.dp,
@@ -158,7 +219,15 @@ fun ResidencesScreen(
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onAddResidence,
onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
onAddResidence()
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
@@ -178,7 +247,15 @@ fun ResidencesScreen(
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { showJoinDialog = true },
onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
showJoinDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),

View File

@@ -18,17 +18,25 @@ import com.example.mycrib.ui.theme.AppSpacing
@Composable
fun UpgradeFeatureScreen(
triggerKey: String,
featureName: String,
featureDescription: String,
icon: ImageVector,
onNavigateBack: () -> Unit
) {
var showUpgradeDialog by remember { mutableStateOf(false) }
// Look up trigger data from cache
val triggerData by remember { derivedStateOf {
com.example.mycrib.cache.SubscriptionCache.upgradeTriggers.value[triggerKey]
} }
// Fallback values if trigger not found
val title = triggerData?.title ?: "Upgrade Required"
val message = triggerData?.message ?: "This feature is available with a Pro subscription."
val buttonText = triggerData?.buttonText ?: "Upgrade to Pro"
Scaffold(
topBar = {
TopAppBar(
title = { Text(featureName, fontWeight = FontWeight.SemiBold) },
title = { Text(title, fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
@@ -63,7 +71,7 @@ fun UpgradeFeatureScreen(
// Title
Text(
featureName,
title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
@@ -71,7 +79,7 @@ fun UpgradeFeatureScreen(
// Description
Text(
featureDescription,
message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@@ -104,7 +112,7 @@ fun UpgradeFeatureScreen(
.padding(horizontal = AppSpacing.lg),
shape = MaterialTheme.shapes.medium
) {
Text("Upgrade to Pro", fontWeight = FontWeight.Bold)
Text(buttonText, fontWeight = FontWeight.Bold)
}
}
}

View File

@@ -5,7 +5,11 @@ import com.example.mycrib.cache.SubscriptionCache
object SubscriptionHelper {
data class UsageCheck(val allowed: Boolean, val triggerKey: String?)
fun canAddProperty(): UsageCheck {
// NOTE: For Android, currentTier should be set from Google Play Billing
// For iOS, tier is managed by SubscriptionCacheWrapper from StoreKit
var currentTier: String = "free"
fun canAddProperty(currentCount: Int = 0): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(true, null) // Allow if no subscription data
@@ -14,18 +18,22 @@ object SubscriptionHelper {
return UsageCheck(true, null)
}
if (subscription.tier == "pro") {
// Pro tier gets unlimited access
if (currentTier == "pro") {
return UsageCheck(true, null)
}
if (subscription.usage.propertiesCount >= (subscription.limits.properties ?: 1)) {
// Get limit for current tier
val limit = subscription.limits[currentTier]?.properties ?: 1
if (currentCount >= limit) {
return UsageCheck(false, "add_second_property")
}
return UsageCheck(true, null)
}
fun canAddTask(): UsageCheck {
fun canAddTask(currentCount: Int = 0): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(true, null)
@@ -34,11 +42,15 @@ object SubscriptionHelper {
return UsageCheck(true, null)
}
if (subscription.tier == "pro") {
// Pro tier gets unlimited access
if (currentTier == "pro") {
return UsageCheck(true, null)
}
if (subscription.usage.tasksCount >= (subscription.limits.tasks ?: 10)) {
// Get limit for current tier
val limit = subscription.limits[currentTier]?.tasks ?: 10
if (currentCount >= limit) {
return UsageCheck(false, "add_11th_task")
}
@@ -55,7 +67,7 @@ object SubscriptionHelper {
}
// Pro users don't see the prompt
if (subscription.tier == "pro") {
if (currentTier == "pro") {
return UsageCheck(false, null)
}
@@ -73,7 +85,7 @@ object SubscriptionHelper {
}
// Pro users don't see the prompt
if (subscription.tier == "pro") {
if (currentTier == "pro") {
return UsageCheck(false, null)
}