diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt index e1ec4ec..40d2581 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt @@ -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, // {"free": {...}, "pro": {...}} @SerialName("limitations_enabled") val limitationsEnabled: Boolean = false // Master toggle ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt index 6f61cb0..4b92dfb 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt @@ -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 { + 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 { 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) { diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/documents/DocumentsTabContent.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/documents/DocumentsTabContent.kt index 34de4fd..a7a4832 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/documents/DocumentsTabContent.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/documents/DocumentsTabContent.kt @@ -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>, 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, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt index 26b2984..0623cdb 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt @@ -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(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 + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt index 08cd827..b93b05b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt @@ -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 ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ProfileScreen.kt index bb92e59..c1ec14f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ProfileScreen.kt @@ -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 } + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index 3472908..a2e3d92 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -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(null) } var taskToArchive by remember { mutableStateOf(null) } val deleteState by residenceViewModel.deleteResidenceState.collectAsState() + var showUpgradePrompt by remember { mutableStateOf(false) } + var upgradeTriggerKey by remember { mutableStateOf(null) } + + // Helper function to check LIVE task count against limits + fun canAddTask(): Pair { + 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).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) ) { diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index 19ba99b..b4c4972 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -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(null) } + + // Helper function to check LIVE property count against limits + fun canAddProperty(): Pair { + 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), diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt index 6bdb28b..f9ac137 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt @@ -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) } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt index 99631af..037fb92 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt @@ -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) } diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index d8e8ede..97772f5 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -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() diff --git a/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift b/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift index 8563814..77199b8 100644 --- a/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift +++ b/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift @@ -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 { diff --git a/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift b/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift index b92f23e..b3192f7 100644 --- a/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift +++ b/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift @@ -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 { diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index 00278b4..eb510f9 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -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() { diff --git a/iosApp/iosApp/Profile/ProfileTabView.swift b/iosApp/iosApp/Profile/ProfileTabView.swift index f4b028f..b245ca5 100644 --- a/iosApp/iosApp/Profile/ProfileTabView.swift +++ b/iosApp/iosApp/Profile/ProfileTabView.swift @@ -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 } } diff --git a/iosApp/iosApp/PushNotifications/AppDelegate.swift b/iosApp/iosApp/PushNotifications/AppDelegate.swift index 876ad89..8cba1ad 100644 --- a/iosApp/iosApp/PushNotifications/AppDelegate.swift +++ b/iosApp/iosApp/PushNotifications/AppDelegate.swift @@ -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 diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 7874321..cd2c07e 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -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") } diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index fe09794..6ccb81e 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -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() diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index b6689ce..7f5a7ba 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -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 { diff --git a/iosApp/iosApp/Subscription/SubscriptionCache.swift b/iosApp/iosApp/Subscription/SubscriptionCache.swift index 3f4d00a..c7497e6 100644 --- a/iosApp/iosApp/Subscription/SubscriptionCache.swift +++ b/iosApp/iosApp/Subscription/SubscriptionCache.swift @@ -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 { } } } + diff --git a/iosApp/iosApp/Subscription/SubscriptionHelper.swift b/iosApp/iosApp/Subscription/SubscriptionHelper.swift deleted file mode 100644 index 5be2674..0000000 --- a/iosApp/iosApp/Subscription/SubscriptionHelper.swift +++ /dev/null @@ -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) - } -} diff --git a/iosApp/iosApp/Subscription/UpgradeFeatureView.swift b/iosApp/iosApp/Subscription/UpgradeFeatureView.swift index 07b9fe9..d876660 100644 --- a/iosApp/iosApp/Subscription/UpgradeFeatureView.swift +++ b/iosApp/iosApp/Subscription/UpgradeFeatureView.swift @@ -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" ) } diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index d48d805..42755a7 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -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") }