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:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user