Add push notification deep linking and sharing subscription checks

- Add deep link navigation from push notifications to specific task column on kanban board
- Fix subscription check in push notification handler to allow navigation when limitations disabled
- Add pendingNavigationTaskId to handle notifications when app isn't ready
- Add ScrollViewReader to AllTasksView for programmatic scrolling to task column
- Add canShareResidence() and canShareContractor() subscription checks (iOS & Android)
- Add test APNS file for simulator push notification testing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-10 23:17:28 -06:00
parent ed14a1c69e
commit cbe073aa21
21 changed files with 723 additions and 127 deletions

View File

@@ -29,6 +29,8 @@ import com.example.casera.util.DateUtils
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.network.ApiResult
import com.example.casera.platform.rememberShareContractor
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -45,6 +47,8 @@ fun ContractorDetailScreen(
var showEditDialog by remember { mutableStateOf(false) }
var showDeleteConfirmation by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) }
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
val shareContractor = rememberShareContractor()
@@ -90,7 +94,15 @@ fun ContractorDetailScreen(
actions = {
when (val state = contractorState) {
is ApiResult.Success -> {
IconButton(onClick = { shareContractor(state.data) }) {
IconButton(onClick = {
val shareCheck = SubscriptionHelper.canShareContractor()
if (shareCheck.allowed) {
shareContractor(state.data)
} else {
upgradeTriggerKey = shareCheck.triggerKey
showUpgradePrompt = true
}
}) {
Icon(Icons.Default.Share, stringResource(Res.string.common_share))
}
IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) {
@@ -557,6 +569,21 @@ fun ContractorDetailScreen(
shape = RoundedCornerShape(16.dp)
)
}
if (showUpgradePrompt && upgradeTriggerKey != null) {
UpgradePromptDialog(
triggerKey = upgradeTriggerKey!!,
onDismiss = {
showUpgradePrompt = false
upgradeTriggerKey = null
},
onUpgrade = {
// TODO: Navigate to subscription purchase screen
showUpgradePrompt = false
upgradeTriggerKey = null
}
)
}
}
@Composable

View File

@@ -345,6 +345,44 @@ fun ProfileScreen(
}
if (SubscriptionHelper.currentTier != "pro") {
// Upgrade Benefits List
Column(
modifier = Modifier.padding(vertical = AppSpacing.sm),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Text(
text = stringResource(Res.string.profile_upgrade_benefits_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
UpgradeBenefitRow(
icon = Icons.Default.Home,
text = stringResource(Res.string.profile_benefit_unlimited_properties)
)
UpgradeBenefitRow(
icon = Icons.Default.Folder,
text = stringResource(Res.string.profile_benefit_document_vault)
)
UpgradeBenefitRow(
icon = Icons.Default.People,
text = stringResource(Res.string.profile_benefit_residence_sharing)
)
UpgradeBenefitRow(
icon = Icons.Default.Share,
text = stringResource(Res.string.profile_benefit_contractor_sharing)
)
UpgradeBenefitRow(
icon = Icons.Default.Notifications,
text = stringResource(Res.string.profile_benefit_actionable_notifications)
)
UpgradeBenefitRow(
icon = Icons.Default.Widgets,
text = stringResource(Res.string.profile_benefit_widgets)
)
}
Button(
onClick = { showUpgradePrompt = true },
modifier = Modifier.fillMaxWidth(),
@@ -556,3 +594,26 @@ fun ProfileScreen(
}
}
}
@Composable
private fun UpgradeBenefitRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -442,7 +442,15 @@ fun ResidenceDetailScreen(
// Share button - only show for primary owners
if (residence.ownerId == currentUser?.id) {
IconButton(
onClick = { shareResidence(residence) },
onClick = {
val shareCheck = SubscriptionHelper.canShareResidence()
if (shareCheck.allowed) {
shareResidence(residence)
} else {
upgradeTriggerKey = shareCheck.triggerKey
showUpgradePrompt = true
}
},
enabled = !shareState.isLoading
) {
if (shareState.isLoading) {
@@ -459,7 +467,13 @@ fun ResidenceDetailScreen(
// Manage Users button - only show for primary owners
if (residence.ownerId == currentUser?.id) {
IconButton(onClick = {
showManageUsersDialog = true
val shareCheck = SubscriptionHelper.canShareResidence()
if (shareCheck.allowed) {
showManageUsersDialog = true
} else {
upgradeTriggerKey = shareCheck.triggerKey
showUpgradePrompt = true
}
}) {
Icon(Icons.Default.People, contentDescription = stringResource(Res.string.properties_manage_users))
}

View File

@@ -70,15 +70,6 @@ fun OnboardingSubscriptionContent(
MaterialTheme.colorScheme.secondary
)
),
SubscriptionBenefit(
icon = Icons.Default.Notifications,
title = stringResource(Res.string.onboarding_subscription_benefit_reminders),
description = stringResource(Res.string.onboarding_subscription_benefit_reminders_desc),
gradientColors = listOf(
MaterialTheme.colorScheme.tertiary,
Color(0xFFFF9500)
)
),
SubscriptionBenefit(
icon = Icons.Default.Folder,
title = stringResource(Res.string.onboarding_subscription_benefit_documents),
@@ -98,13 +89,31 @@ fun OnboardingSubscriptionContent(
)
),
SubscriptionBenefit(
icon = Icons.Default.TrendingUp,
title = stringResource(Res.string.onboarding_subscription_benefit_insights),
description = stringResource(Res.string.onboarding_subscription_benefit_insights_desc),
icon = Icons.Default.Share,
title = stringResource(Res.string.onboarding_subscription_benefit_contractor_sharing),
description = stringResource(Res.string.onboarding_subscription_benefit_contractor_sharing_desc),
gradientColors = listOf(
MaterialTheme.colorScheme.error,
MaterialTheme.colorScheme.tertiary,
Color(0xFFFF9500)
)
),
SubscriptionBenefit(
icon = Icons.Default.Notifications,
title = stringResource(Res.string.onboarding_subscription_benefit_notifications),
description = stringResource(Res.string.onboarding_subscription_benefit_notifications_desc),
gradientColors = listOf(
Color(0xFFFF3B30),
Color(0xFFFF6961)
)
),
SubscriptionBenefit(
icon = Icons.Default.Widgets,
title = stringResource(Res.string.onboarding_subscription_benefit_widgets),
description = stringResource(Res.string.onboarding_subscription_benefit_widgets_desc),
gradientColors = listOf(
Color(0xFF007AFF),
Color(0xFF5AC8FA)
)
)
)

View File

@@ -75,6 +75,24 @@ fun OnboardingValuePropsContent(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.tertiary
)
),
FeatureItem(
icon = Icons.Default.Notifications,
title = stringResource(Res.string.onboarding_feature_notifications_title),
description = stringResource(Res.string.onboarding_feature_notifications_desc),
gradientColors = listOf(
Color(0xFFFF9500),
Color(0xFFFF6961)
)
),
FeatureItem(
icon = Icons.Default.Widgets,
title = stringResource(Res.string.onboarding_feature_widgets_title),
description = stringResource(Res.string.onboarding_feature_widgets_desc),
gradientColors = listOf(
Color(0xFF34C759),
Color(0xFF30D158)
)
)
)

View File

@@ -267,6 +267,50 @@ object SubscriptionHelper {
return UsageCheck(true, null)
}
// ===== RESIDENCE SHARING =====
/**
* Check if user can share a residence (Pro feature).
* Returns true (blocked) when limitations are ON and user is not pro.
*/
fun canShareResidence(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(true, null) // Allow if no subscription data (fallback)
if (!subscription.limitationsEnabled) {
return UsageCheck(true, null) // Limitations disabled, allow everything
}
if (currentTier == "pro") {
return UsageCheck(true, null) // Pro tier can share
}
// Free users cannot share residences
return UsageCheck(false, "share_residence")
}
// ===== CONTRACTOR SHARING =====
/**
* Check if user can share a contractor (Pro feature).
* Returns true (blocked) when limitations are ON and user is not pro.
*/
fun canShareContractor(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
?: return UsageCheck(true, null) // Allow if no subscription data (fallback)
if (!subscription.limitationsEnabled) {
return UsageCheck(true, null) // Limitations disabled, allow everything
}
if (currentTier == "pro") {
return UsageCheck(true, null) // Pro tier can share
}
// Free users cannot share contractors
return UsageCheck(false, "share_contractor")
}
// ===== DEPRECATED - Keep for backwards compatibility =====
@Deprecated("Use isContractorsBlocked() instead", ReplaceWith("isContractorsBlocked()"))