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)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ import ComposeApp
|
||||
|
||||
struct ContractorsListView: View {
|
||||
@StateObject private var viewModel = ContractorViewModel()
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@State private var searchText = ""
|
||||
@State private var showingAddSheet = false
|
||||
@State private var selectedSpecialty: String? = nil
|
||||
@State private var showFavoritesOnly = false
|
||||
@State private var showSpecialtyFilter = false
|
||||
@State private var showingUpgradePrompt = false
|
||||
|
||||
// Lookups from DataCache
|
||||
@State private var contractorSpecialties: [ContractorSpecialty] = []
|
||||
@@ -73,9 +75,18 @@ struct ContractorsListView: View {
|
||||
Spacer()
|
||||
} else if contractors.isEmpty {
|
||||
Spacer()
|
||||
EmptyContractorsView(
|
||||
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
|
||||
)
|
||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
|
||||
// User can add contractors (limit > 0) - show empty state
|
||||
EmptyContractorsView(
|
||||
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
|
||||
)
|
||||
} else {
|
||||
// User is blocked (limit = 0) - show upgrade prompt
|
||||
UpgradeFeatureView(
|
||||
triggerKey: "view_contractors",
|
||||
icon: "person.2.fill"
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
@@ -143,7 +154,15 @@ struct ContractorsListView: View {
|
||||
}
|
||||
|
||||
// Add Button
|
||||
Button(action: { showingAddSheet = true }) {
|
||||
Button(action: {
|
||||
// Check LIVE contractor count before adding
|
||||
let currentCount = viewModel.contractors.count
|
||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
|
||||
showingUpgradePrompt = true
|
||||
} else {
|
||||
showingAddSheet = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
@@ -161,6 +180,9 @@ struct ContractorsListView: View {
|
||||
)
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
.sheet(isPresented: $showingUpgradePrompt) {
|
||||
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
|
||||
}
|
||||
.onAppear {
|
||||
loadContractors()
|
||||
loadContractorSpecialties()
|
||||
|
||||
@@ -3,6 +3,7 @@ import ComposeApp
|
||||
|
||||
struct DocumentsTabContent: View {
|
||||
@ObservedObject var viewModel: DocumentViewModel
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
let searchText: String
|
||||
|
||||
var filteredDocuments: [Document] {
|
||||
@@ -27,11 +28,20 @@ struct DocumentsTabContent: View {
|
||||
Spacer()
|
||||
} else if filteredDocuments.isEmpty {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "doc",
|
||||
title: "No documents found",
|
||||
message: "Add documents related to your residence"
|
||||
)
|
||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
|
||||
// User can add documents (limit > 0) - show empty state
|
||||
EmptyStateView(
|
||||
icon: "doc",
|
||||
title: "No documents found",
|
||||
message: "Add documents related to your residence"
|
||||
)
|
||||
} else {
|
||||
// User is blocked (limit = 0) - show upgrade prompt
|
||||
UpgradeFeatureView(
|
||||
triggerKey: "view_documents",
|
||||
icon: "doc.text.fill"
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
|
||||
@@ -3,6 +3,7 @@ import ComposeApp
|
||||
|
||||
struct WarrantiesTabContent: View {
|
||||
@ObservedObject var viewModel: DocumentViewModel
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
let searchText: String
|
||||
|
||||
var filteredWarranties: [Document] {
|
||||
@@ -29,11 +30,20 @@ struct WarrantiesTabContent: View {
|
||||
Spacer()
|
||||
} else if filteredWarranties.isEmpty {
|
||||
Spacer()
|
||||
EmptyStateView(
|
||||
icon: "doc.text.viewfinder",
|
||||
title: "No warranties found",
|
||||
message: "Add warranties to track coverage periods"
|
||||
)
|
||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
|
||||
// User can add documents (limit > 0) - show empty state
|
||||
EmptyStateView(
|
||||
icon: "doc.text.viewfinder",
|
||||
title: "No warranties found",
|
||||
message: "Add warranties to track coverage periods"
|
||||
)
|
||||
} else {
|
||||
// User is blocked (limit = 0) - show upgrade prompt
|
||||
UpgradeFeatureView(
|
||||
triggerKey: "view_documents",
|
||||
icon: "doc.text.fill"
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
|
||||
@@ -8,6 +8,7 @@ enum DocumentWarrantyTab {
|
||||
|
||||
struct DocumentsWarrantiesView: View {
|
||||
@StateObject private var documentViewModel = DocumentViewModel()
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@State private var selectedTab: DocumentWarrantyTab = .warranties
|
||||
@State private var searchText = ""
|
||||
@State private var selectedCategory: String? = nil
|
||||
@@ -15,6 +16,7 @@ struct DocumentsWarrantiesView: View {
|
||||
@State private var showActiveOnly = true
|
||||
@State private var showFilterMenu = false
|
||||
@State private var showAddSheet = false
|
||||
@State private var showingUpgradePrompt = false
|
||||
|
||||
let residenceId: Int32?
|
||||
|
||||
@@ -154,7 +156,13 @@ struct DocumentsWarrantiesView: View {
|
||||
|
||||
// Add Button
|
||||
Button(action: {
|
||||
showAddSheet = true
|
||||
// Check LIVE document count before adding
|
||||
let currentCount = documentViewModel.documents.count
|
||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "documents") {
|
||||
showingUpgradePrompt = true
|
||||
} else {
|
||||
showAddSheet = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
@@ -182,6 +190,9 @@ struct DocumentsWarrantiesView: View {
|
||||
documentViewModel: documentViewModel
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingUpgradePrompt) {
|
||||
UpgradePromptView(triggerKey: "view_documents", isPresented: $showingUpgradePrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadWarranties() {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct ProfileTabView: View {
|
||||
@EnvironmentObject private var themeManager: ThemeManager
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@StateObject private var storeKitManager = StoreKitManager.shared
|
||||
@State private var showingProfileEdit = false
|
||||
@State private var showingLogoutAlert = false
|
||||
@State private var showingThemeSelection = false
|
||||
@State private var showUpgradePrompt = false
|
||||
@State private var showRestoreSuccess = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -47,6 +52,61 @@ struct ProfileTabView: View {
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
|
||||
// Subscription Section - Only show if limitations are enabled on backend
|
||||
if let subscription = subscriptionCache.currentSubscription, subscription.limitationsEnabled {
|
||||
Section("Subscription") {
|
||||
HStack {
|
||||
Image(systemName: "crown.fill")
|
||||
.foregroundColor(subscriptionCache.currentTier == "pro" ? Color.appAccent : Color.appTextSecondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(subscriptionCache.currentTier == "pro" ? "Pro Plan" : "Free Plan")
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
if subscriptionCache.currentTier == "pro",
|
||||
let expiresAt = subscription.expiresAt {
|
||||
Text("Active until \(formatDate(expiresAt))")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
} else {
|
||||
Text("Limited features")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if subscriptionCache.currentTier != "pro" {
|
||||
Button(action: { showUpgradePrompt = true }) {
|
||||
Label("Upgrade to Pro", systemImage: "arrow.up.circle.fill")
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Label("Manage Subscription", systemImage: "gearshape.fill")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await storeKitManager.restorePurchases()
|
||||
showRestoreSuccess = true
|
||||
}
|
||||
}) {
|
||||
Label("Restore Purchases", systemImage: "arrow.clockwise")
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
}
|
||||
|
||||
Section("Appearance") {
|
||||
Button(action: {
|
||||
showingThemeSelection = true
|
||||
@@ -111,5 +171,23 @@ struct ProfileTabView: View {
|
||||
} message: {
|
||||
Text("Are you sure you want to log out?")
|
||||
}
|
||||
.sheet(isPresented: $showUpgradePrompt) {
|
||||
UpgradePromptView(triggerKey: "user_profile", isPresented: $showUpgradePrompt)
|
||||
}
|
||||
.alert("Purchases Restored", isPresented: $showRestoreSuccess) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Your purchases have been restored successfully.")
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ dateString: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
if let date = formatter.date(from: dateString) {
|
||||
let displayFormatter = DateFormatter()
|
||||
displayFormatter.dateStyle = .medium
|
||||
return displayFormatter.string(from: date)
|
||||
}
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,13 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
PushNotificationManager.shared.clearBadge()
|
||||
}
|
||||
|
||||
// Initialize StoreKit and check for existing subscriptions
|
||||
// This ensures we have the user's subscription status ready before they interact
|
||||
Task {
|
||||
_ = StoreKitManager.shared
|
||||
print("✅ StoreKit initialized at app launch")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -31,6 +38,12 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
Task { @MainActor in
|
||||
PushNotificationManager.shared.clearBadge()
|
||||
}
|
||||
|
||||
// Refresh StoreKit subscription status when app comes to foreground
|
||||
// This ensures we have the latest subscription state if it changed while app was in background
|
||||
Task {
|
||||
await StoreKitManager.shared.refreshSubscriptionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote Notifications
|
||||
|
||||
@@ -25,7 +25,9 @@ struct ResidenceDetailView: View {
|
||||
@State private var showReportConfirmation = false
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var isDeleting = false
|
||||
|
||||
@State private var showingUpgradePrompt = false
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -120,6 +122,9 @@ struct ResidenceDetailView: View {
|
||||
Text("Are you sure you want to archive \"\(task.title)\"? You can unarchive it later from archived tasks.")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingUpgradePrompt) {
|
||||
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
|
||||
}
|
||||
|
||||
// MARK: onChange & lifecycle
|
||||
.onChange(of: viewModel.reportMessage) { message in
|
||||
@@ -255,7 +260,13 @@ private extension ResidenceDetailView {
|
||||
}
|
||||
|
||||
Button {
|
||||
showAddTask = true
|
||||
// Check LIVE task count before adding
|
||||
let totalTasks = tasksResponse?.columns.reduce(0) { $0 + $1.tasks.count } ?? 0
|
||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTasks, limitKey: "tasks") {
|
||||
showingUpgradePrompt = true
|
||||
} else {
|
||||
showAddTask = true
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ struct ResidencesListView: View {
|
||||
@StateObject private var viewModel = ResidenceViewModel()
|
||||
@State private var showingAddResidence = false
|
||||
@State private var showingJoinResidence = false
|
||||
@State private var showingUpgradePrompt = false
|
||||
@StateObject private var authManager = AuthenticationManager.shared
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
|
||||
|
||||
var body: some View {
|
||||
@@ -71,7 +73,13 @@ struct ResidencesListView: View {
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
showingJoinResidence = true
|
||||
// Check if we should show upgrade prompt before joining
|
||||
let currentCount = viewModel.myResidences?.residences.count ?? 0
|
||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "properties") {
|
||||
showingUpgradePrompt = true
|
||||
} else {
|
||||
showingJoinResidence = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "person.badge.plus")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
@@ -79,7 +87,13 @@ struct ResidencesListView: View {
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showingAddResidence = true
|
||||
// Check if we should show upgrade prompt before adding
|
||||
let currentCount = viewModel.myResidences?.residences.count ?? 0
|
||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "properties") {
|
||||
showingUpgradePrompt = true
|
||||
} else {
|
||||
showingAddResidence = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
@@ -101,6 +115,9 @@ struct ResidencesListView: View {
|
||||
viewModel.loadMyResidences()
|
||||
})
|
||||
}
|
||||
.sheet(isPresented: $showingUpgradePrompt) {
|
||||
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
|
||||
}
|
||||
.onAppear {
|
||||
if authManager.isAuthenticated {
|
||||
viewModel.loadMyResidences()
|
||||
|
||||
@@ -128,6 +128,13 @@ class StoreKitManager: ObservableObject {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Refresh subscription status from StoreKit
|
||||
/// Call this when app comes to foreground to ensure we have latest status
|
||||
func refreshSubscriptionStatus() async {
|
||||
await updatePurchasedProducts()
|
||||
print("🔄 StoreKit: Subscription status refreshed")
|
||||
}
|
||||
|
||||
/// Update purchased product IDs
|
||||
@MainActor
|
||||
private func updatePurchasedProducts() async {
|
||||
|
||||
@@ -4,24 +4,104 @@ import ComposeApp
|
||||
/// Swift wrapper for accessing Kotlin SubscriptionCache
|
||||
class SubscriptionCacheWrapper: ObservableObject {
|
||||
static let shared = SubscriptionCacheWrapper()
|
||||
|
||||
|
||||
@Published var currentSubscription: SubscriptionStatus?
|
||||
@Published var upgradeTriggers: [String: UpgradeTriggerData] = [:]
|
||||
@Published var featureBenefits: [FeatureBenefit] = []
|
||||
@Published var promotions: [Promotion] = []
|
||||
|
||||
|
||||
/// Current tier based on StoreKit purchases
|
||||
var currentTier: String {
|
||||
// Check if user has any active subscriptions via StoreKit
|
||||
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 from StoreKit
|
||||
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 != nil ? Int(truncating: tierLimits.properties!) : nil
|
||||
case "tasks":
|
||||
limit = tierLimits.tasks != nil ? Int(truncating: tierLimits.tasks!) : nil
|
||||
case "contractors":
|
||||
limit = tierLimits.contractors != nil ? Int(truncating: tierLimits.contractors!) : nil
|
||||
case "documents":
|
||||
limit = tierLimits.documents != nil ? Int(truncating: tierLimits.documents!) : nil
|
||||
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)
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Initialize with current values from Kotlin cache
|
||||
Task {
|
||||
await observeSubscriptionStatus()
|
||||
await observeUpgradeTriggers()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private func observeSubscriptionStatus() {
|
||||
// Update from Kotlin cache
|
||||
if let subscription = ComposeApp.SubscriptionCache.shared.currentSubscription.value as? SubscriptionStatus {
|
||||
self.currentSubscription = subscription
|
||||
print("📊 Subscription Status: currentTier=\(currentTier), limitationsEnabled=\(subscription.limitationsEnabled)")
|
||||
print(" 📊 Free Tier Limits - Properties: \(subscription.limits["free"]?.properties), Tasks: \(subscription.limits["free"]?.tasks), Contractors: \(subscription.limits["free"]?.contractors), Documents: \(subscription.limits["free"]?.documents)")
|
||||
print(" 📊 Pro Tier Limits - Properties: \(subscription.limits["pro"]?.properties), Tasks: \(subscription.limits["pro"]?.tasks), Contractors: \(subscription.limits["pro"]?.contractors), Documents: \(subscription.limits["pro"]?.documents)")
|
||||
} else {
|
||||
print("⚠️ No subscription status in cache")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func observeUpgradeTriggers() {
|
||||
// Update from Kotlin cache
|
||||
let kotlinTriggers = ComposeApp.SubscriptionCache.shared.upgradeTriggers.value as? [String: UpgradeTriggerData]
|
||||
if let triggers = kotlinTriggers {
|
||||
self.upgradeTriggers = triggers
|
||||
}
|
||||
}
|
||||
|
||||
func refreshFromCache() {
|
||||
Task {
|
||||
await observeSubscriptionStatus()
|
||||
await observeUpgradeTriggers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,3 +122,4 @@ class SubscriptionCacheWrapper: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
|
||||
/// Swift wrapper for Kotlin SubscriptionHelper
|
||||
class SubscriptionHelper {
|
||||
static func canAddProperty() -> (allowed: Bool, triggerKey: String?) {
|
||||
let result = ComposeApp.SubscriptionHelper.shared.canAddProperty()
|
||||
return (result.allowed, result.triggerKey)
|
||||
}
|
||||
|
||||
static func canAddTask() -> (allowed: Bool, triggerKey: String?) {
|
||||
let result = ComposeApp.SubscriptionHelper.shared.canAddTask()
|
||||
return (result.allowed, result.triggerKey)
|
||||
}
|
||||
|
||||
static func shouldShowUpgradePromptForContractors() -> (showPrompt: Bool, triggerKey: String?) {
|
||||
let result = ComposeApp.SubscriptionHelper.shared.shouldShowUpgradePromptForContractors()
|
||||
return (result.allowed, result.triggerKey)
|
||||
}
|
||||
|
||||
static func shouldShowUpgradePromptForDocuments() -> (showPrompt: Bool, triggerKey: String?) {
|
||||
let result = ComposeApp.SubscriptionHelper.shared.shouldShowUpgradePromptForDocuments()
|
||||
return (result.allowed, result.triggerKey)
|
||||
}
|
||||
}
|
||||
@@ -3,34 +3,52 @@ import ComposeApp
|
||||
|
||||
struct UpgradeFeatureView: View {
|
||||
let triggerKey: String
|
||||
let featureName: String
|
||||
let featureDescription: String
|
||||
let icon: String
|
||||
|
||||
|
||||
@State private var showUpgradePrompt = false
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
|
||||
|
||||
// Look up trigger data from cache
|
||||
private var triggerData: UpgradeTriggerData? {
|
||||
subscriptionCache.upgradeTriggers[triggerKey]
|
||||
}
|
||||
|
||||
// Fallback values if trigger not found
|
||||
private var title: String {
|
||||
triggerData?.title ?? "Upgrade Required"
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
triggerData?.message ?? "This feature is available with a Pro subscription."
|
||||
}
|
||||
|
||||
private var buttonText: String {
|
||||
triggerData?.buttonText ?? "Upgrade to Pro"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppSpacing.xl) {
|
||||
Spacer()
|
||||
|
||||
|
||||
// Feature Icon
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(Color.appPrimary.gradient)
|
||||
|
||||
|
||||
// Title
|
||||
Text(featureName)
|
||||
Text(title)
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
|
||||
// Description
|
||||
Text(featureDescription)
|
||||
Text(message)
|
||||
.font(.body)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
|
||||
|
||||
// Upgrade Message
|
||||
Text("This feature is available with Pro")
|
||||
.font(.subheadline.weight(.medium))
|
||||
@@ -39,12 +57,12 @@ struct UpgradeFeatureView: View {
|
||||
.padding(.vertical, AppSpacing.sm)
|
||||
.background(Color.appAccent.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
|
||||
|
||||
// Upgrade Button
|
||||
Button(action: {
|
||||
showUpgradePrompt = true
|
||||
}) {
|
||||
Text("Upgrade to Pro")
|
||||
Text(buttonText)
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -54,7 +72,7 @@ struct UpgradeFeatureView: View {
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.top, AppSpacing.lg)
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -68,8 +86,6 @@ struct UpgradeFeatureView: View {
|
||||
#Preview {
|
||||
UpgradeFeatureView(
|
||||
triggerKey: "view_contractors",
|
||||
featureName: "Contractors",
|
||||
featureDescription: "Track and manage all your contractors in one place",
|
||||
icon: "person.2.fill"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,20 +4,28 @@ import ComposeApp
|
||||
struct AllTasksView: View {
|
||||
@StateObject private var taskViewModel = TaskViewModel()
|
||||
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@State private var tasksResponse: TaskColumnsResponse?
|
||||
@State private var isLoadingTasks = false
|
||||
@State private var tasksError: String?
|
||||
@State private var showAddTask = false
|
||||
@State private var showEditTask = false
|
||||
@State private var showingUpgradePrompt = false
|
||||
@State private var selectedTaskForEdit: TaskDetail?
|
||||
@State private var selectedTaskForComplete: TaskDetail?
|
||||
|
||||
|
||||
@State private var selectedTaskForArchive: TaskDetail?
|
||||
@State private var showArchiveConfirmation = false
|
||||
|
||||
|
||||
@State private var selectedTaskForCancel: TaskDetail?
|
||||
@State private var showCancelConfirmation = false
|
||||
|
||||
// Count total tasks across all columns
|
||||
private var totalTaskCount: Int {
|
||||
guard let response = tasksResponse else { return 0 }
|
||||
return response.columns.reduce(0) { $0 + $1.tasks.count }
|
||||
}
|
||||
|
||||
private var hasNoTasks: Bool {
|
||||
guard let response = tasksResponse else { return true }
|
||||
return response.columns.allSatisfy { $0.tasks.isEmpty }
|
||||
@@ -46,6 +54,9 @@ struct AllTasksView: View {
|
||||
loadAllTasks()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingUpgradePrompt) {
|
||||
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
|
||||
}
|
||||
.alert("Archive Task", isPresented: $showArchiveConfirmation) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
selectedTaskForArchive = nil
|
||||
@@ -129,7 +140,12 @@ struct AllTasksView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button(action: {
|
||||
showAddTask = true
|
||||
// Check if we should show upgrade prompt before adding
|
||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
|
||||
showingUpgradePrompt = true
|
||||
} else {
|
||||
showAddTask = true
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "plus")
|
||||
@@ -224,7 +240,12 @@ struct AllTasksView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
showAddTask = true
|
||||
// Check if we should show upgrade prompt before adding
|
||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
|
||||
showingUpgradePrompt = true
|
||||
} else {
|
||||
showAddTask = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user