Optimize subscription tier management and empty state logic

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

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

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

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

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

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

View File

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

View File

@@ -2,6 +2,7 @@ package com.example.mycrib.network
import com.example.mycrib.cache.DataCache import com.example.mycrib.cache.DataCache
import com.example.mycrib.cache.DataPrefetchManager import com.example.mycrib.cache.DataPrefetchManager
import com.example.mycrib.cache.SubscriptionCache
import com.example.mycrib.models.* import com.example.mycrib.models.*
import com.example.mycrib.network.* import com.example.mycrib.network.*
import com.example.mycrib.storage.TokenStorage import com.example.mycrib.storage.TokenStorage
@@ -26,16 +27,49 @@ object APILayer {
private val authApi = AuthApi() private val authApi = AuthApi()
private val lookupsApi = LookupsApi() private val lookupsApi = LookupsApi()
private val notificationApi = NotificationApi() private val notificationApi = NotificationApi()
private val subscriptionApi = SubscriptionApi()
private val prefetchManager = DataPrefetchManager.getInstance() private val prefetchManager = DataPrefetchManager.getInstance()
// ==================== Lookups Operations ==================== // ==================== 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. * Initialize all lookup data. Should be called once after login.
* Loads all reference data (residence types, task categories, priorities, etc.) into cache. * Loads all reference data (residence types, task categories, priorities, etc.) into cache.
*/ */
suspend fun initializeLookups(): ApiResult<Unit> { suspend fun initializeLookups(): ApiResult<Unit> {
if (DataCache.lookupsInitialized.value) { 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) return ApiResult.Success(Unit)
} }
@@ -50,6 +84,17 @@ object APILayer {
val taskCategoriesResult = lookupsApi.getTaskCategories(token) val taskCategoriesResult = lookupsApi.getTaskCategories(token)
val contractorSpecialtiesResult = lookupsApi.getContractorSpecialties(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 // Update cache with successful results
if (residenceTypesResult is ApiResult.Success) { if (residenceTypesResult is ApiResult.Success) {
DataCache.updateResidenceTypes(residenceTypesResult.data) DataCache.updateResidenceTypes(residenceTypesResult.data)
@@ -70,6 +115,22 @@ object APILayer {
DataCache.updateContractorSpecialties(contractorSpecialtiesResult.data) 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() DataCache.markLookupsInitialized()
return ApiResult.Success(Unit) return ApiResult.Success(Unit)
} catch (e: Exception) { } catch (e: Exception) {

View File

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

View File

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

View File

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

View File

@@ -18,12 +18,16 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.mycrib.ui.components.HandleErrors import com.example.mycrib.ui.components.HandleErrors
import com.example.mycrib.ui.components.common.ErrorCard import com.example.mycrib.ui.components.common.ErrorCard
import com.example.mycrib.ui.components.dialogs.ThemePickerDialog 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.AppRadius
import com.example.mycrib.ui.theme.AppSpacing import com.example.mycrib.ui.theme.AppSpacing
import com.example.mycrib.ui.theme.ThemeManager import com.example.mycrib.ui.theme.ThemeManager
import com.example.mycrib.viewmodel.AuthViewModel import com.example.mycrib.viewmodel.AuthViewModel
import com.example.mycrib.network.ApiResult import com.example.mycrib.network.ApiResult
import com.example.mycrib.storage.TokenStorage 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -40,9 +44,11 @@ fun ProfileScreen(
var successMessage by remember { mutableStateOf("") } var successMessage by remember { mutableStateOf("") }
var isLoadingUser by remember { mutableStateOf(true) } var isLoadingUser by remember { mutableStateOf(true) }
var showThemePicker by remember { mutableStateOf(false) } var showThemePicker by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) }
val updateState by viewModel.updateProfileState.collectAsState() val updateState by viewModel.updateProfileState.collectAsState()
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } } val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
val currentSubscription by SubscriptionCache.currentSubscription
// Handle errors for profile update // Handle errors for profile update
updateState.HandleErrors( 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( Text(
"Profile Information", "Profile Information",
@@ -360,5 +441,18 @@ fun ProfileScreen(
onDismiss = { showThemePicker = false } onDismiss = { showThemePicker = false }
) )
} }
// Upgrade Prompt Dialog
if (showUpgradePrompt) {
UpgradePromptDialog(
triggerKey = "profile_upgrade",
onUpgrade = {
// Handle upgrade action - on Android this would open Play Store
// For now, just close the dialog
showUpgradePrompt = false
},
onDismiss = { showUpgradePrompt = false }
)
}
} }
} }

View File

@@ -30,6 +30,9 @@ import com.example.mycrib.viewmodel.TaskViewModel
import com.example.mycrib.models.Residence import com.example.mycrib.models.Residence
import com.example.mycrib.models.TaskDetail import com.example.mycrib.models.TaskDetail
import com.example.mycrib.network.ApiResult 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -63,6 +66,33 @@ fun ResidenceDetailScreen(
var taskToCancel by remember { mutableStateOf<TaskDetail?>(null) } var taskToCancel by remember { mutableStateOf<TaskDetail?>(null) }
var taskToArchive by remember { mutableStateOf<TaskDetail?>(null) } var taskToArchive by remember { mutableStateOf<TaskDetail?>(null) }
val deleteState by residenceViewModel.deleteResidenceState.collectAsState() 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) { LaunchedEffect(residenceId) {
residenceViewModel.getResidence(residenceId) { result -> 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) { if (showManageUsersDialog && residenceState is ApiResult.Success) {
val residence = (residenceState as ApiResult.Success<Residence>).data val residence = (residenceState as ApiResult.Success<Residence>).data
ManageUsersDialog( ManageUsersDialog(
@@ -399,7 +444,15 @@ fun ResidenceDetailScreen(
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { showNewTaskDialog = true }, onClick = {
val (allowed, triggerKey) = canAddTask()
if (allowed) {
showNewTaskDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(16.dp)
) { ) {

View File

@@ -26,6 +26,9 @@ import com.example.mycrib.ui.components.common.StatItem
import com.example.mycrib.ui.components.residence.TaskStatChip import com.example.mycrib.ui.components.residence.TaskStatChip
import com.example.mycrib.viewmodel.ResidenceViewModel import com.example.mycrib.viewmodel.ResidenceViewModel
import com.example.mycrib.network.ApiResult 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -40,6 +43,33 @@ fun ResidencesScreen(
val myResidencesState by viewModel.myResidencesState.collectAsState() val myResidencesState by viewModel.myResidencesState.collectAsState()
var showJoinDialog by remember { mutableStateOf(false) } var showJoinDialog by remember { mutableStateOf(false) }
var isRefreshing 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) { LaunchedEffect(Unit) {
viewModel.loadMyResidences() 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -81,7 +126,15 @@ fun ResidencesScreen(
) )
}, },
actions = { 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") Icon(Icons.Default.GroupAdd, contentDescription = "Join with Code")
} }
IconButton(onClick = onNavigateToProfile) { IconButton(onClick = onNavigateToProfile) {
@@ -104,7 +157,15 @@ fun ResidencesScreen(
if (hasResidences) { if (hasResidences) {
Box(modifier = Modifier.padding(bottom = 80.dp)) { Box(modifier = Modifier.padding(bottom = 80.dp)) {
FloatingActionButton( FloatingActionButton(
onClick = onAddResidence, onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
onAddResidence()
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
elevation = FloatingActionButtonDefaults.elevation( elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 8.dp, defaultElevation = 8.dp,
@@ -158,7 +219,15 @@ fun ResidencesScreen(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button( Button(
onClick = onAddResidence, onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
onAddResidence()
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.7f) .fillMaxWidth(0.7f)
.height(56.dp), .height(56.dp),
@@ -178,7 +247,15 @@ fun ResidencesScreen(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedButton( OutlinedButton(
onClick = { showJoinDialog = true }, onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
showJoinDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.7f) .fillMaxWidth(0.7f)
.height(56.dp), .height(56.dp),

View File

@@ -18,17 +18,25 @@ import com.example.mycrib.ui.theme.AppSpacing
@Composable @Composable
fun UpgradeFeatureScreen( fun UpgradeFeatureScreen(
triggerKey: String, triggerKey: String,
featureName: String,
featureDescription: String,
icon: ImageVector, icon: ImageVector,
onNavigateBack: () -> Unit onNavigateBack: () -> Unit
) { ) {
var showUpgradeDialog by remember { mutableStateOf(false) } 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(featureName, fontWeight = FontWeight.SemiBold) }, title = { Text(title, fontWeight = FontWeight.SemiBold) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back") Icon(Icons.Default.ArrowBack, "Back")
@@ -63,7 +71,7 @@ fun UpgradeFeatureScreen(
// Title // Title
Text( Text(
featureName, title,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@@ -71,7 +79,7 @@ fun UpgradeFeatureScreen(
// Description // Description
Text( Text(
featureDescription, message,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@@ -104,7 +112,7 @@ fun UpgradeFeatureScreen(
.padding(horizontal = AppSpacing.lg), .padding(horizontal = AppSpacing.lg),
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) { ) {
Text("Upgrade to Pro", fontWeight = FontWeight.Bold) Text(buttonText, fontWeight = FontWeight.Bold)
} }
} }
} }

View File

@@ -5,7 +5,11 @@ import com.example.mycrib.cache.SubscriptionCache
object SubscriptionHelper { object SubscriptionHelper {
data class UsageCheck(val allowed: Boolean, val triggerKey: String?) 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 val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(true, null) // Allow if no subscription data ?: return UsageCheck(true, null) // Allow if no subscription data
@@ -14,18 +18,22 @@ object SubscriptionHelper {
return UsageCheck(true, null) return UsageCheck(true, null)
} }
if (subscription.tier == "pro") { // Pro tier gets unlimited access
if (currentTier == "pro") {
return UsageCheck(true, null) 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(false, "add_second_property")
} }
return UsageCheck(true, null) return UsageCheck(true, null)
} }
fun canAddTask(): UsageCheck { fun canAddTask(currentCount: Int = 0): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(true, null) ?: return UsageCheck(true, null)
@@ -34,11 +42,15 @@ object SubscriptionHelper {
return UsageCheck(true, null) return UsageCheck(true, null)
} }
if (subscription.tier == "pro") { // Pro tier gets unlimited access
if (currentTier == "pro") {
return UsageCheck(true, null) 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") return UsageCheck(false, "add_11th_task")
} }
@@ -55,7 +67,7 @@ object SubscriptionHelper {
} }
// Pro users don't see the prompt // Pro users don't see the prompt
if (subscription.tier == "pro") { if (currentTier == "pro") {
return UsageCheck(false, null) return UsageCheck(false, null)
} }
@@ -73,7 +85,7 @@ object SubscriptionHelper {
} }
// Pro users don't see the prompt // Pro users don't see the prompt
if (subscription.tier == "pro") { if (currentTier == "pro") {
return UsageCheck(false, null) return UsageCheck(false, null)
} }

View File

@@ -3,11 +3,13 @@ import ComposeApp
struct ContractorsListView: View { struct ContractorsListView: View {
@StateObject private var viewModel = ContractorViewModel() @StateObject private var viewModel = ContractorViewModel()
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@State private var searchText = "" @State private var searchText = ""
@State private var showingAddSheet = false @State private var showingAddSheet = false
@State private var selectedSpecialty: String? = nil @State private var selectedSpecialty: String? = nil
@State private var showFavoritesOnly = false @State private var showFavoritesOnly = false
@State private var showSpecialtyFilter = false @State private var showSpecialtyFilter = false
@State private var showingUpgradePrompt = false
// Lookups from DataCache // Lookups from DataCache
@State private var contractorSpecialties: [ContractorSpecialty] = [] @State private var contractorSpecialties: [ContractorSpecialty] = []
@@ -73,9 +75,18 @@ struct ContractorsListView: View {
Spacer() Spacer()
} else if contractors.isEmpty { } else if contractors.isEmpty {
Spacer() Spacer()
EmptyContractorsView( if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty // 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() Spacer()
} else { } else {
ScrollView { ScrollView {
@@ -143,7 +154,15 @@ struct ContractorsListView: View {
} }
// Add Button // 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") Image(systemName: "plus.circle.fill")
.font(.title2) .font(.title2)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
@@ -161,6 +180,9 @@ struct ContractorsListView: View {
) )
.presentationDetents([.large]) .presentationDetents([.large])
} }
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
}
.onAppear { .onAppear {
loadContractors() loadContractors()
loadContractorSpecialties() loadContractorSpecialties()

View File

@@ -3,6 +3,7 @@ import ComposeApp
struct DocumentsTabContent: View { struct DocumentsTabContent: View {
@ObservedObject var viewModel: DocumentViewModel @ObservedObject var viewModel: DocumentViewModel
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
let searchText: String let searchText: String
var filteredDocuments: [Document] { var filteredDocuments: [Document] {
@@ -27,11 +28,20 @@ struct DocumentsTabContent: View {
Spacer() Spacer()
} else if filteredDocuments.isEmpty { } else if filteredDocuments.isEmpty {
Spacer() Spacer()
EmptyStateView( if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
icon: "doc", // User can add documents (limit > 0) - show empty state
title: "No documents found", EmptyStateView(
message: "Add documents related to your residence" 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() Spacer()
} else { } else {
ScrollView { ScrollView {

View File

@@ -3,6 +3,7 @@ import ComposeApp
struct WarrantiesTabContent: View { struct WarrantiesTabContent: View {
@ObservedObject var viewModel: DocumentViewModel @ObservedObject var viewModel: DocumentViewModel
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
let searchText: String let searchText: String
var filteredWarranties: [Document] { var filteredWarranties: [Document] {
@@ -29,11 +30,20 @@ struct WarrantiesTabContent: View {
Spacer() Spacer()
} else if filteredWarranties.isEmpty { } else if filteredWarranties.isEmpty {
Spacer() Spacer()
EmptyStateView( if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
icon: "doc.text.viewfinder", // User can add documents (limit > 0) - show empty state
title: "No warranties found", EmptyStateView(
message: "Add warranties to track coverage periods" 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() Spacer()
} else { } else {
ScrollView { ScrollView {

View File

@@ -8,6 +8,7 @@ enum DocumentWarrantyTab {
struct DocumentsWarrantiesView: View { struct DocumentsWarrantiesView: View {
@StateObject private var documentViewModel = DocumentViewModel() @StateObject private var documentViewModel = DocumentViewModel()
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@State private var selectedTab: DocumentWarrantyTab = .warranties @State private var selectedTab: DocumentWarrantyTab = .warranties
@State private var searchText = "" @State private var searchText = ""
@State private var selectedCategory: String? = nil @State private var selectedCategory: String? = nil
@@ -15,6 +16,7 @@ struct DocumentsWarrantiesView: View {
@State private var showActiveOnly = true @State private var showActiveOnly = true
@State private var showFilterMenu = false @State private var showFilterMenu = false
@State private var showAddSheet = false @State private var showAddSheet = false
@State private var showingUpgradePrompt = false
let residenceId: Int32? let residenceId: Int32?
@@ -154,7 +156,13 @@ struct DocumentsWarrantiesView: View {
// Add Button // Add Button
Button(action: { 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") Image(systemName: "plus.circle.fill")
.font(.title2) .font(.title2)
@@ -182,6 +190,9 @@ struct DocumentsWarrantiesView: View {
documentViewModel: documentViewModel documentViewModel: documentViewModel
) )
} }
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "view_documents", isPresented: $showingUpgradePrompt)
}
} }
private func loadWarranties() { private func loadWarranties() {

View File

@@ -1,10 +1,15 @@
import SwiftUI import SwiftUI
import ComposeApp
struct ProfileTabView: View { struct ProfileTabView: View {
@EnvironmentObject private var themeManager: ThemeManager @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 showingProfileEdit = false
@State private var showingLogoutAlert = false @State private var showingLogoutAlert = false
@State private var showingThemeSelection = false @State private var showingThemeSelection = false
@State private var showUpgradePrompt = false
@State private var showRestoreSuccess = false
var body: some View { var body: some View {
List { List {
@@ -47,6 +52,61 @@ struct ProfileTabView: View {
} }
.listRowBackground(Color.appBackgroundSecondary) .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") { Section("Appearance") {
Button(action: { Button(action: {
showingThemeSelection = true showingThemeSelection = true
@@ -111,5 +171,23 @@ struct ProfileTabView: View {
} message: { } message: {
Text("Are you sure you want to log out?") 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
} }
} }

View File

@@ -21,6 +21,13 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
PushNotificationManager.shared.clearBadge() 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 return true
} }
@@ -31,6 +38,12 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
Task { @MainActor in Task { @MainActor in
PushNotificationManager.shared.clearBadge() 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 // MARK: - Remote Notifications

View File

@@ -25,7 +25,9 @@ struct ResidenceDetailView: View {
@State private var showReportConfirmation = false @State private var showReportConfirmation = false
@State private var showDeleteConfirmation = false @State private var showDeleteConfirmation = false
@State private var isDeleting = false @State private var isDeleting = false
@State private var showingUpgradePrompt = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { 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.") 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 // MARK: onChange & lifecycle
.onChange(of: viewModel.reportMessage) { message in .onChange(of: viewModel.reportMessage) { message in
@@ -255,7 +260,13 @@ private extension ResidenceDetailView {
} }
Button { 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: { } label: {
Image(systemName: "plus") Image(systemName: "plus")
} }

View File

@@ -5,7 +5,9 @@ struct ResidencesListView: View {
@StateObject private var viewModel = ResidenceViewModel() @StateObject private var viewModel = ResidenceViewModel()
@State private var showingAddResidence = false @State private var showingAddResidence = false
@State private var showingJoinResidence = false @State private var showingJoinResidence = false
@State private var showingUpgradePrompt = false
@StateObject private var authManager = AuthenticationManager.shared @StateObject private var authManager = AuthenticationManager.shared
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
var body: some View { var body: some View {
@@ -71,7 +73,13 @@ struct ResidencesListView: View {
.toolbar { .toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: { 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") Image(systemName: "person.badge.plus")
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 18, weight: .semibold))
@@ -79,7 +87,13 @@ struct ResidencesListView: View {
} }
Button(action: { 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") Image(systemName: "plus.circle.fill")
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 22, weight: .semibold))
@@ -101,6 +115,9 @@ struct ResidencesListView: View {
viewModel.loadMyResidences() viewModel.loadMyResidences()
}) })
} }
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
}
.onAppear { .onAppear {
if authManager.isAuthenticated { if authManager.isAuthenticated {
viewModel.loadMyResidences() viewModel.loadMyResidences()

View File

@@ -128,6 +128,13 @@ class StoreKitManager: ObservableObject {
return false 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 /// Update purchased product IDs
@MainActor @MainActor
private func updatePurchasedProducts() async { private func updatePurchasedProducts() async {

View File

@@ -4,24 +4,104 @@ import ComposeApp
/// Swift wrapper for accessing Kotlin SubscriptionCache /// Swift wrapper for accessing Kotlin SubscriptionCache
class SubscriptionCacheWrapper: ObservableObject { class SubscriptionCacheWrapper: ObservableObject {
static let shared = SubscriptionCacheWrapper() static let shared = SubscriptionCacheWrapper()
@Published var currentSubscription: SubscriptionStatus? @Published var currentSubscription: SubscriptionStatus?
@Published var upgradeTriggers: [String: UpgradeTriggerData] = [:] @Published var upgradeTriggers: [String: UpgradeTriggerData] = [:]
@Published var featureBenefits: [FeatureBenefit] = [] @Published var featureBenefits: [FeatureBenefit] = []
@Published var promotions: [Promotion] = [] @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() { private init() {
// Initialize with current values from Kotlin cache // Initialize with current values from Kotlin cache
Task { Task {
await observeSubscriptionStatus() await observeSubscriptionStatus()
await observeUpgradeTriggers()
} }
} }
@MainActor @MainActor
private func observeSubscriptionStatus() { private func observeSubscriptionStatus() {
// Update from Kotlin cache // Update from Kotlin cache
if let subscription = ComposeApp.SubscriptionCache.shared.currentSubscription.value as? SubscriptionStatus { if let subscription = ComposeApp.SubscriptionCache.shared.currentSubscription.value as? SubscriptionStatus {
self.currentSubscription = subscription 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 {
} }
} }
} }

View File

@@ -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)
}
}

View File

@@ -3,34 +3,52 @@ import ComposeApp
struct UpgradeFeatureView: View { struct UpgradeFeatureView: View {
let triggerKey: String let triggerKey: String
let featureName: String
let featureDescription: String
let icon: String let icon: String
@State private var showUpgradePrompt = false @State private var showUpgradePrompt = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @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 { var body: some View {
VStack(spacing: AppSpacing.xl) { VStack(spacing: AppSpacing.xl) {
Spacer() Spacer()
// Feature Icon // Feature Icon
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 80)) .font(.system(size: 80))
.foregroundStyle(Color.appPrimary.gradient) .foregroundStyle(Color.appPrimary.gradient)
// Title // Title
Text(featureName) Text(title)
.font(.title.weight(.bold)) .font(.title.weight(.bold))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, AppSpacing.xl)
// Description // Description
Text(featureDescription) Text(message)
.font(.body) .font(.body)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, AppSpacing.xl)
// Upgrade Message // Upgrade Message
Text("This feature is available with Pro") Text("This feature is available with Pro")
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
@@ -39,12 +57,12 @@ struct UpgradeFeatureView: View {
.padding(.vertical, AppSpacing.sm) .padding(.vertical, AppSpacing.sm)
.background(Color.appAccent.opacity(0.1)) .background(Color.appAccent.opacity(0.1))
.cornerRadius(AppRadius.md) .cornerRadius(AppRadius.md)
// Upgrade Button // Upgrade Button
Button(action: { Button(action: {
showUpgradePrompt = true showUpgradePrompt = true
}) { }) {
Text("Upgrade to Pro") Text(buttonText)
.font(.headline) .font(.headline)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -54,7 +72,7 @@ struct UpgradeFeatureView: View {
} }
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, AppSpacing.xl)
.padding(.top, AppSpacing.lg) .padding(.top, AppSpacing.lg)
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -68,8 +86,6 @@ struct UpgradeFeatureView: View {
#Preview { #Preview {
UpgradeFeatureView( UpgradeFeatureView(
triggerKey: "view_contractors", triggerKey: "view_contractors",
featureName: "Contractors",
featureDescription: "Track and manage all your contractors in one place",
icon: "person.2.fill" icon: "person.2.fill"
) )
} }

View File

@@ -4,20 +4,28 @@ import ComposeApp
struct AllTasksView: View { struct AllTasksView: View {
@StateObject private var taskViewModel = TaskViewModel() @StateObject private var taskViewModel = TaskViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel() @StateObject private var residenceViewModel = ResidenceViewModel()
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@State private var tasksResponse: TaskColumnsResponse? @State private var tasksResponse: TaskColumnsResponse?
@State private var isLoadingTasks = false @State private var isLoadingTasks = false
@State private var tasksError: String? @State private var tasksError: String?
@State private var showAddTask = false @State private var showAddTask = false
@State private var showEditTask = false @State private var showEditTask = false
@State private var showingUpgradePrompt = false
@State private var selectedTaskForEdit: TaskDetail? @State private var selectedTaskForEdit: TaskDetail?
@State private var selectedTaskForComplete: TaskDetail? @State private var selectedTaskForComplete: TaskDetail?
@State private var selectedTaskForArchive: TaskDetail? @State private var selectedTaskForArchive: TaskDetail?
@State private var showArchiveConfirmation = false @State private var showArchiveConfirmation = false
@State private var selectedTaskForCancel: TaskDetail? @State private var selectedTaskForCancel: TaskDetail?
@State private var showCancelConfirmation = false @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 { private var hasNoTasks: Bool {
guard let response = tasksResponse else { return true } guard let response = tasksResponse else { return true }
return response.columns.allSatisfy { $0.tasks.isEmpty } return response.columns.allSatisfy { $0.tasks.isEmpty }
@@ -46,6 +54,9 @@ struct AllTasksView: View {
loadAllTasks() loadAllTasks()
} }
} }
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
}
.alert("Archive Task", isPresented: $showArchiveConfirmation) { .alert("Archive Task", isPresented: $showArchiveConfirmation) {
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
selectedTaskForArchive = nil selectedTaskForArchive = nil
@@ -129,7 +140,12 @@ struct AllTasksView: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button(action: { 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) { HStack(spacing: 8) {
Image(systemName: "plus") Image(systemName: "plus")
@@ -224,7 +240,12 @@ struct AllTasksView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { 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") Image(systemName: "plus")
} }