Optimize subscription tier management and empty state logic

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

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 {
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"
)
}

View File

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