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

@@ -72,6 +72,8 @@
<string name="properties_join_title">Join Property</string>
<string name="properties_join_code_label">Enter Share Code</string>
<string name="properties_join_button">Join</string>
<string name="properties_share_upgrade_title">Pro Feature</string>
<string name="properties_share_upgrade_message">Sharing residences is a Pro feature. Upgrade to invite family members to collaborate on home maintenance.</string>
<string name="properties_users_title">Property Members</string>
<string name="properties_owner">Owner</string>
<string name="properties_member">Member</string>
@@ -248,6 +250,8 @@
<string name="contractors_delete_warning">Are you sure you want to delete this contractor? This action cannot be undone.</string>
<string name="contractors_completed_tasks">%1$d completed tasks</string>
<string name="contractors_share">Share Contractor</string>
<string name="contractors_share_upgrade_title">Pro Feature</string>
<string name="contractors_share_upgrade_message">Sharing contractors is a Pro feature. Upgrade to share your trusted contractors with friends and family.</string>
<string name="contractors_import_title">Import Contractor</string>
<string name="contractors_import_message">Would you like to import this contractor?</string>
<string name="contractors_import_success">Contractor Imported</string>
@@ -368,6 +372,14 @@
<string name="profile_support">Support</string>
<string name="profile_contact_support">Contact Support</string>
<string name="profile_contact_support_subtitle">Get help with your account</string>
<string name="profile_upgrade_benefits_title">Unlock Premium Features</string>
<string name="profile_upgrade_benefits_subtitle">Upgrade to Pro for the complete experience</string>
<string name="profile_benefit_unlimited_properties">Unlimited Properties</string>
<string name="profile_benefit_document_vault">Document &amp; Warranty Storage</string>
<string name="profile_benefit_residence_sharing">Residence Sharing</string>
<string name="profile_benefit_contractor_sharing">Contractor Sharing</string>
<string name="profile_benefit_actionable_notifications">Actionable Notifications</string>
<string name="profile_benefit_widgets">Home Screen Widgets</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
@@ -516,6 +528,10 @@
<string name="onboarding_feature_contractors_desc">Keep all your contractor contacts organized and easily accessible</string>
<string name="onboarding_feature_family_title">Share With Family</string>
<string name="onboarding_feature_family_desc">Invite family members to collaborate on home maintenance together</string>
<string name="onboarding_feature_notifications_title">Smart Notifications</string>
<string name="onboarding_feature_notifications_desc">Get actionable reminders that let you complete tasks right from the notification</string>
<string name="onboarding_feature_widgets_title">Home Screen Widgets</string>
<string name="onboarding_feature_widgets_desc">Quick access to tasks and reminders directly from your home screen</string>
<!-- Onboarding - Name Residence -->
<string name="onboarding_name_residence_title">Name Your Home</string>
@@ -570,6 +586,12 @@
<string name="onboarding_subscription_benefit_family_desc">Get everyone on the same page</string>
<string name="onboarding_subscription_benefit_insights">Spending Insights</string>
<string name="onboarding_subscription_benefit_insights_desc">See where your money goes</string>
<string name="onboarding_subscription_benefit_contractor_sharing">Contractor Sharing</string>
<string name="onboarding_subscription_benefit_contractor_sharing_desc">Share your trusted contractors with family and friends</string>
<string name="onboarding_subscription_benefit_widgets">Home Screen Widgets</string>
<string name="onboarding_subscription_benefit_widgets_desc">Quick actions right from your home screen</string>
<string name="onboarding_subscription_benefit_notifications">Actionable Notifications</string>
<string name="onboarding_subscription_benefit_notifications_desc">Complete tasks directly from notifications</string>
<string name="onboarding_subscription_choose_plan">Choose your plan</string>
<string name="onboarding_subscription_monthly">Monthly</string>
<string name="onboarding_subscription_yearly">Yearly</string>

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

View File

@@ -43,6 +43,9 @@ struct CompleteTaskIntent: AppIntent {
// Mark task as pending completion immediately (optimistic UI)
WidgetActionManager.shared.markTaskPendingCompletion(taskId: taskId)
// Reload widget immediately to update task list and stats
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
// Get auth token and API URL from shared container
guard let token = WidgetActionManager.shared.getAuthToken() else {
print("CompleteTaskIntent: No auth token available")

View File

@@ -202,6 +202,52 @@ struct SimpleEntry: TimelineEntry {
var nextTask: CacheManager.CustomTask? {
upcomingTasks.first
}
/// Tasks due within the next 7 days
var dueThisWeekCount: Int {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let weekFromNow = calendar.date(byAdding: .day, value: 7, to: today)!
return upcomingTasks.filter { task in
guard let dueDateString = task.dueDate else { return false }
guard let dueDate = parseDate(dueDateString) else { return false }
let dueDay = calendar.startOfDay(for: dueDate)
return dueDay <= weekFromNow
}.count
}
/// Tasks due within the next 30 days
var dueNext30DaysCount: Int {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let thirtyDaysFromNow = calendar.date(byAdding: .day, value: 30, to: today)!
return upcomingTasks.filter { task in
guard let dueDateString = task.dueDate else { return false }
guard let dueDate = parseDate(dueDateString) else { return false }
let dueDay = calendar.startOfDay(for: dueDate)
return dueDay <= thirtyDaysFromNow
}.count
}
/// Parse date string to Date
private func parseDate(_ dateString: String) -> Date? {
let dateOnlyFormatter = DateFormatter()
dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
if let date = dateOnlyFormatter.date(from: dateString) {
return date
}
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = isoFormatter.date(from: dateString) {
return date
}
isoFormatter.formatOptions = [.withInternetDateTime]
return isoFormatter.date(from: dateString)
}
}
struct CaseraEntryView : View {
@@ -461,26 +507,12 @@ struct InteractiveTaskRowView: View {
struct LargeWidgetView: View {
let entry: SimpleEntry
private var maxTasksToShow: Int {
entry.upcomingTasks.count > 6 ? 5 : 6
}
private var maxTasksToShow: Int { 5 }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Header
HStack(spacing: 6) {
Spacer()
Text("\(entry.taskCount)")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(.blue)
Text(entry.taskCount == 1 ? "task" : "tasks")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 0) {
if entry.upcomingTasks.isEmpty {
// Empty state - centered
Spacer()
VStack(spacing: 12) {
@@ -494,25 +526,99 @@ struct LargeWidgetView: View {
.frame(maxWidth: .infinity)
Spacer()
// Stats even when empty
LargeWidgetStatsView(entry: entry)
} else {
// Show tasks with interactive buttons
ForEach(Array(entry.upcomingTasks.prefix(maxTasksToShow).enumerated()), id: \.element.id) { index, task in
LargeInteractiveTaskRowView(task: task)
// Tasks section - always at top
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(entry.upcomingTasks.prefix(maxTasksToShow).enumerated()), id: \.element.id) { index, task in
LargeInteractiveTaskRowView(task: task)
}
if entry.upcomingTasks.count > maxTasksToShow {
Text("+ \(entry.upcomingTasks.count - maxTasksToShow) more")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 2)
}
}
if entry.upcomingTasks.count > 6 {
Text("+ \(entry.upcomingTasks.count - 5) more tasks")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 2)
}
Spacer(minLength: 12)
// Stats section at bottom
LargeWidgetStatsView(entry: entry)
}
}
.padding(14)
}
}
// MARK: - Large Widget Stats View
struct LargeWidgetStatsView: View {
let entry: SimpleEntry
var body: some View {
HStack(spacing: 0) {
// Total Tasks
StatItem(
value: entry.taskCount,
label: "Total",
color: .blue
)
Divider()
.frame(height: 30)
// Due This Week
StatItem(
value: entry.dueThisWeekCount,
label: "This Week",
color: .orange
)
Divider()
.frame(height: 30)
// Due Next 30 Days
StatItem(
value: entry.dueNext30DaysCount,
label: "Next 30 Days",
color: .green
)
}
.padding(.vertical, 10)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.primary.opacity(0.05))
)
}
}
// MARK: - Stat Item View
struct StatItem: View {
let value: Int
let label: String
let color: Color
var body: some View {
VStack(spacing: 2) {
Text("\(value)")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(color)
Text(label)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Large Interactive Task Row
struct LargeInteractiveTaskRowView: View {
let task: CacheManager.CustomTask
@@ -530,7 +636,7 @@ struct LargeInteractiveTaskRowView: View {
VStack(alignment: .leading, spacing: 2) {
Text(task.title)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
.lineLimit(2)
.foregroundStyle(.primary)
HStack(spacing: 10) {

View File

@@ -13,6 +13,8 @@ struct ContractorDetailView: View {
@State private var showingDeleteAlert = false
@State private var showingShareSheet = false
@State private var shareFileURL: URL?
@State private var showingUpgradePrompt = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
var body: some View {
ZStack {
@@ -27,7 +29,13 @@ struct ContractorDetailView: View {
ToolbarItem(placement: .navigationBarTrailing) {
if let contractor = viewModel.selectedContractor {
Menu {
Button(action: { shareContractor(contractor) }) {
Button(action: {
if subscriptionCache.canShareContractor() {
shareContractor(contractor)
} else {
showingUpgradePrompt = true
}
}) {
Label(L10n.Common.share, systemImage: "square.and.arrow.up")
}
@@ -61,6 +69,9 @@ struct ContractorDetailView: View {
ShareSheet(activityItems: [url])
}
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "share_contractor", isPresented: $showingUpgradePrompt)
}
.sheet(isPresented: $showingEditSheet) {
ContractorFormSheet(
contractor: viewModel.selectedContractor,

View File

@@ -561,6 +561,15 @@ enum L10n {
static var support: String { String(localized: "profile_support") }
static var contactSupport: String { String(localized: "profile_contact_support") }
static var contactSupportSubtitle: String { String(localized: "profile_contact_support_subtitle") }
// Upgrade Benefits
static var unlockPremiumFeatures: String { String(localized: "profile_unlock_premium_features") }
static var benefitUnlimitedProperties: String { String(localized: "profile_benefit_unlimited_properties") }
static var benefitDocumentVault: String { String(localized: "profile_benefit_document_vault") }
static var benefitResidenceSharing: String { String(localized: "profile_benefit_residence_sharing") }
static var benefitContractorSharing: String { String(localized: "profile_benefit_contractor_sharing") }
static var benefitActionableNotifications: String { String(localized: "profile_benefit_actionable_notifications") }
static var benefitWidgets: String { String(localized: "profile_benefit_widgets") }
}
// MARK: - Settings

View File

@@ -19557,6 +19557,83 @@
}
}
},
"profile_unlock_premium_features" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unlock Premium Features"
}
}
}
},
"profile_benefit_unlimited_properties" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unlimited Properties"
}
}
}
},
"profile_benefit_document_vault" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Document & Warranty Storage"
}
}
}
},
"profile_benefit_residence_sharing" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Residence Sharing"
}
}
}
},
"profile_benefit_contractor_sharing" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Contractor Sharing"
}
}
}
},
"profile_benefit_actionable_notifications" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Actionable Notifications"
}
}
}
},
"profile_benefit_widgets" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Home Screen Widgets"
}
}
}
},
"profile_support" : {
"extractionState" : "manual",
"localizations" : {

View File

@@ -4,6 +4,7 @@ struct MainTabView: View {
@EnvironmentObject private var themeManager: ThemeManager
@State private var selectedTab = 0
@StateObject private var authManager = AuthenticationManager.shared
@ObservedObject private var pushManager = PushNotificationManager.shared
var refreshID: UUID
var body: some View {
@@ -52,6 +53,13 @@ struct MainTabView: View {
.onChange(of: authManager.isAuthenticated) { _ in
selectedTab = 0
}
// Check for pending navigation when view appears (app launched from notification)
.onAppear {
if pushManager.pendingNavigationTaskId != nil {
selectedTab = 1 // Switch to Tasks tab
// Note: Don't clear here - AllTasksView will handle navigation and clear it
}
}
// Handle push notification deep links - switch to appropriate tab
// The actual task navigation is handled by AllTasksView
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in

View File

@@ -16,12 +16,6 @@ struct OnboardingSubscriptionContent: View {
description: "Track every home you own—vacation houses, rentals, you name it",
gradient: [Color.appPrimary, Color.appSecondary]
),
SubscriptionBenefit(
icon: "bell.badge.fill",
title: "Smart Reminders",
description: "Never miss a maintenance deadline. Ever.",
gradient: [Color.appAccent, Color(hex: "#FF9500") ?? .orange]
),
SubscriptionBenefit(
icon: "doc.badge.plus",
title: "Document Vault",
@@ -35,10 +29,22 @@ struct OnboardingSubscriptionContent: View {
gradient: [Color(hex: "#AF52DE") ?? .purple, Color(hex: "#BF5AF2") ?? .purple]
),
SubscriptionBenefit(
icon: "chart.line.uptrend.xyaxis",
title: "Spending Insights",
description: "See where your money goes and plan smarter",
icon: "square.and.arrow.up.fill",
title: "Contractor Sharing",
description: "Share your trusted contractors with family and friends",
gradient: [Color.appAccent, Color(hex: "#FF9500") ?? .orange]
),
SubscriptionBenefit(
icon: "bell.badge.fill",
title: "Actionable Notifications",
description: "Complete tasks directly from notifications—no app opening needed",
gradient: [Color(hex: "#FF3B30") ?? .red, Color(hex: "#FF6961") ?? .red]
),
SubscriptionBenefit(
icon: "square.grid.2x2.fill",
title: "Home Screen Widgets",
description: "Quick actions right from your home screen",
gradient: [Color(hex: "#007AFF") ?? .blue, Color(hex: "#5AC8FA") ?? .blue]
)
]

View File

@@ -17,15 +17,6 @@ struct OnboardingValuePropsContent: View {
statNumber: "$6,000+",
statLabel: "spent yearly on repairs that could've been prevented"
),
FeatureHighlight(
icon: "dollarsign.circle.fill",
title: "Save Thousands",
subtitle: "Prevention beats repair every time",
description: "Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.",
gradient: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green],
statNumber: "46%",
statLabel: "of homeowners spent $5,000+ on surprise repairs"
),
FeatureHighlight(
icon: "doc.text.fill",
title: "Warranties at Your Fingertips",
@@ -43,6 +34,24 @@ struct OnboardingValuePropsContent: View {
gradient: [Color(hex: "#AF52DE") ?? .purple, Color(hex: "#BF5AF2") ?? .purple],
statNumber: "56%",
statLabel: "say sharing chores is key to a happy home"
),
FeatureHighlight(
icon: "bell.badge.fill",
title: "Smart Notifications",
subtitle: "Act right from your lock screen",
description: "Get actionable reminders that let you complete tasks right from the notification. No need to even open the app.",
gradient: [Color(hex: "#FF3B30") ?? .red, Color(hex: "#FF6961") ?? .red],
statNumber: "3x",
statLabel: "faster task completion with actionable notifications"
),
FeatureHighlight(
icon: "square.grid.2x2.fill",
title: "Home Screen Widgets",
subtitle: "Your tasks at a glance",
description: "Quick access to upcoming tasks and reminders directly from your home screen. Stay on top of maintenance without opening the app.",
gradient: [Color(hex: "#007AFF") ?? .blue, Color(hex: "#5AC8FA") ?? .blue],
statNumber: "68%",
statLabel: "of widget users complete tasks on time"
)
]

View File

@@ -89,6 +89,23 @@ struct ProfileTabView: View {
.padding(.vertical, 4)
if subscriptionCache.currentTier != "pro" {
// Upgrade Benefits List
VStack(alignment: .leading, spacing: 8) {
Text(L10n.Profile.unlockPremiumFeatures)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(Color.appPrimary)
.padding(.top, 4)
UpgradeBenefitRow(icon: "building.2.fill", text: L10n.Profile.benefitUnlimitedProperties)
UpgradeBenefitRow(icon: "doc.fill", text: L10n.Profile.benefitDocumentVault)
UpgradeBenefitRow(icon: "person.2.fill", text: L10n.Profile.benefitResidenceSharing)
UpgradeBenefitRow(icon: "square.and.arrow.up.fill", text: L10n.Profile.benefitContractorSharing)
UpgradeBenefitRow(icon: "bell.badge.fill", text: L10n.Profile.benefitActionableNotifications)
UpgradeBenefitRow(icon: "square.grid.2x2.fill", text: L10n.Profile.benefitWidgets)
}
.padding(.vertical, 4)
Button(action: { showUpgradePrompt = true }) {
Label(L10n.Profile.upgradeToPro, systemImage: "arrow.up.circle.fill")
.foregroundColor(Color.appPrimary)
@@ -223,3 +240,23 @@ struct ProfileTabView: View {
}
}
}
// MARK: - Upgrade Benefit Row
private struct UpgradeBenefitRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.caption)
.foregroundColor(Color.appPrimary)
.frame(width: 18)
Text(text)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
}
}

View File

@@ -9,6 +9,9 @@ class PushNotificationManager: NSObject, ObservableObject {
@Published var deviceToken: String?
@Published var notificationPermissionGranted = false
/// Pending task ID to navigate to (set when notification is tapped before app is ready)
@Published var pendingNavigationTaskId: Int?
private let registeredTokenKey = "com.casera.registeredDeviceToken"
/// The last token that was successfully registered with the backend
@@ -175,18 +178,23 @@ class PushNotificationManager: NSObject, ObservableObject {
}
// Check subscription status to determine navigation
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" ||
!(SubscriptionCacheWrapper.shared.currentSubscription?.limitationsEnabled ?? true)
// Allow navigation if: Pro user, OR limitations are disabled, OR subscription hasn't loaded yet
let subscription = SubscriptionCacheWrapper.shared.currentSubscription
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro"
let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded
let canNavigateToTask = isPremium || !limitationsEnabled
if isPremium {
// Premium user - navigate to task detail
print("📬 Push nav check: isPremium=\(isPremium), limitationsEnabled=\(limitationsEnabled), canNavigate=\(canNavigateToTask), subscription=\(subscription != nil ? "loaded" : "nil")")
if canNavigateToTask {
// Navigate to task detail
if let taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) {
navigateToTask(taskId: taskId)
} else if let taskId = userInfo["task_id"] as? Int {
navigateToTask(taskId: taskId)
}
} else {
// Free user - navigate to home
// Free user with limitations enabled - navigate to home
navigateToHome()
}
}
@@ -203,11 +211,14 @@ class PushNotificationManager: NSObject, ObservableObject {
}
// Check subscription status
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" ||
!(SubscriptionCacheWrapper.shared.currentSubscription?.limitationsEnabled ?? true)
// Allow actions if: Pro user, OR limitations are disabled, OR subscription hasn't loaded yet
let subscription = SubscriptionCacheWrapper.shared.currentSubscription
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro"
let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded
let canPerformAction = isPremium || !limitationsEnabled
guard isPremium else {
// Free user shouldn't see actions, but if they somehow do, go to home
guard canPerformAction else {
// Free user with limitations enabled shouldn't see actions, but if they somehow do, go to home
navigateToHome()
return
}
@@ -375,6 +386,8 @@ class PushNotificationManager: NSObject, ObservableObject {
private func navigateToTask(taskId: Int) {
print("📱 Navigating to task \(taskId)")
// Store pending navigation in case MainTabView isn't ready yet
pendingNavigationTaskId = taskId
NotificationCenter.default.post(
name: .navigateToTask,
object: nil,
@@ -382,6 +395,11 @@ class PushNotificationManager: NSObject, ObservableObject {
)
}
/// Clear pending navigation after it has been handled
func clearPendingNavigation() {
pendingNavigationTaskId = nil
}
private func navigateToEditTask(taskId: Int) {
print("✏️ Navigating to edit task \(taskId)")
NotificationCenter.default.post(

View File

@@ -32,6 +32,7 @@ struct ResidenceDetailView: View {
@State private var showDeleteConfirmation = false
@State private var isDeleting = false
@State private var showingUpgradePrompt = false
@State private var upgradeTriggerKey = ""
@State private var showShareSheet = false
@State private var shareFileURL: URL?
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@@ -147,7 +148,7 @@ struct ResidenceDetailView: View {
}
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
UpgradePromptView(triggerKey: upgradeTriggerKey.isEmpty ? "add_11th_task" : upgradeTriggerKey, isPresented: $showingUpgradePrompt)
}
.sheet(isPresented: $showShareSheet) {
if let url = shareFileURL {
@@ -359,7 +360,12 @@ private extension ResidenceDetailView {
// Share Residence button (owner only)
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
Button {
shareResidence(residence)
if subscriptionCache.canShareResidence() {
shareResidence(residence)
} else {
upgradeTriggerKey = "share_residence"
showingUpgradePrompt = true
}
} label: {
if sharingManager.isGeneratingPackage {
ProgressView()
@@ -370,10 +376,25 @@ private extension ResidenceDetailView {
.disabled(sharingManager.isGeneratingPackage)
}
// Manage Users button (owner only)
if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) {
Button {
if subscriptionCache.canShareResidence() {
showManageUsers = true
} else {
upgradeTriggerKey = "share_residence"
showingUpgradePrompt = true
}
} label: {
Image(systemName: "person.2")
}
}
Button {
// Check LIVE task count before adding
let totalTasks = tasksResponse?.columns.reduce(0) { $0 + $1.tasks.count } ?? 0
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTasks, limitKey: "tasks") {
upgradeTriggerKey = "add_11th_task"
showingUpgradePrompt = true
} else {
showAddTask = true

View File

@@ -68,6 +68,28 @@ class SubscriptionCacheWrapper: ObservableObject {
currentTier == "free" && (currentSubscription?.limitationsEnabled ?? false)
}
/// Check if user can share residences (Pro feature)
/// - Returns: true if allowed, false if should show upgrade prompt
func canShareResidence() -> Bool {
// If limitations are disabled globally, allow
guard let subscription = currentSubscription, subscription.limitationsEnabled else {
return true
}
// Pro tier can share
return currentTier == "pro"
}
/// Check if user can share contractors (Pro feature)
/// - Returns: true if allowed, false if should show upgrade prompt
func canShareContractor() -> Bool {
// If limitations are disabled globally, allow
guard let subscription = currentSubscription, subscription.limitationsEnabled else {
return true
}
// Pro tier can share
return currentTier == "pro"
}
private init() {
// Start observation of Kotlin cache
Task { @MainActor in

View File

@@ -20,6 +20,8 @@ struct AllTasksView: View {
// Deep link task ID to open (from push notification)
@State private var pendingTaskId: Int32?
// Column index to scroll to (for deep link navigation)
@State private var scrollToColumnIndex: Int?
// Use ViewModel's computed properties
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
@@ -100,6 +102,11 @@ struct AllTasksView: View {
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown)
// Check for pending navigation from push notification (app launched from notification)
if let taskId = PushNotificationManager.shared.pendingNavigationTaskId {
pendingTaskId = Int32(taskId)
}
// Check if widget completed a task - force refresh if dirty
if WidgetDataManager.shared.areTasksDirty() {
WidgetDataManager.shared.clearDirtyFlag()
@@ -111,27 +118,38 @@ struct AllTasksView: View {
}
// Handle push notification deep links
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in
print("📬 AllTasksView received .navigateToTask notification")
if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int {
print("📬 Setting pendingTaskId to \(taskId)")
pendingTaskId = Int32(taskId)
// If tasks are already loaded, try to navigate immediately
if let response = tasksResponse {
print("📬 Tasks already loaded, attempting immediate navigation")
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
}
} else {
print("📬 Failed to extract taskId from notification userInfo: \(notification.userInfo ?? [:])")
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in
print("📬 AllTasksView received .navigateToEditTask notification")
if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int {
print("📬 Setting pendingTaskId to \(taskId)")
pendingTaskId = Int32(taskId)
// If tasks are already loaded, try to navigate immediately
if let response = tasksResponse {
print("📬 Tasks already loaded, attempting immediate navigation")
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
}
}
}
// When tasks load and we have a pending task ID, open the edit sheet
// When tasks load and we have a pending task ID, scroll to column and open the edit sheet
.onChange(of: tasksResponse) { response in
print("📬 tasksResponse changed, pendingTaskId=\(pendingTaskId?.description ?? "nil")")
if let taskId = pendingTaskId, let response = response {
// Find the task in all columns
let allTasks = response.columns.flatMap { $0.tasks }
if let task = allTasks.first(where: { $0.id == taskId }) {
selectedTaskForEdit = task
showEditTask = true
pendingTaskId = nil
}
navigateToTaskInKanban(taskId: taskId, response: response)
}
}
// Check dirty flag when app returns from background (widget may have completed a task)
@@ -209,59 +227,73 @@ struct AllTasksView: View {
}
.padding()
} else {
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: [
GridItem(.flexible(), spacing: 16)
], spacing: 16) {
// Dynamically create columns from response
ForEach(Array(tasksResponse.columns.enumerated()), id: \.element.name) { index, column in
DynamicTaskColumnView(
column: column,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true
},
onCancelTask: { task in
selectedTaskForCancel = task
showCancelConfirmation = true
},
onUncancelTask: { taskId in
taskViewModel.uncancelTask(id: taskId) { _ in
loadAllTasks()
}
},
onMarkInProgress: { taskId in
taskViewModel.markInProgress(id: taskId) { success in
if success {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: [
GridItem(.flexible(), spacing: 16)
], spacing: 16) {
// Dynamically create columns from response
ForEach(Array(tasksResponse.columns.enumerated()), id: \.element.name) { index, column in
DynamicTaskColumnView(
column: column,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true
},
onCancelTask: { task in
selectedTaskForCancel = task
showCancelConfirmation = true
},
onUncancelTask: { taskId in
taskViewModel.uncancelTask(id: taskId) { _ in
loadAllTasks()
}
},
onMarkInProgress: { taskId in
taskViewModel.markInProgress(id: taskId) { success in
if success {
loadAllTasks()
}
}
},
onCompleteTask: { task in
selectedTaskForComplete = task
},
onArchiveTask: { task in
selectedTaskForArchive = task
showArchiveConfirmation = true
},
onUnarchiveTask: { taskId in
taskViewModel.unarchiveTask(id: taskId) { _ in
loadAllTasks()
}
}
},
onCompleteTask: { task in
selectedTaskForComplete = task
},
onArchiveTask: { task in
selectedTaskForArchive = task
showArchiveConfirmation = true
},
onUnarchiveTask: { taskId in
taskViewModel.unarchiveTask(id: taskId) { _ in
loadAllTasks()
}
)
.frame(width: 350)
.id(index) // Add ID for ScrollViewReader
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.8)
.scaleEffect(phase.isIdentity ? 1 : 0.95)
}
)
.frame(width: 350)
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.8)
.scaleEffect(phase.isIdentity ? 1 : 0.95)
}
}
.scrollTargetLayout()
.padding(16)
}
.scrollTargetBehavior(.viewAligned)
.onChange(of: scrollToColumnIndex) { columnIndex in
if let columnIndex = columnIndex {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(columnIndex, anchor: .leading)
}
// Clear after scrolling
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
scrollToColumnIndex = nil
}
}
}
.scrollTargetLayout()
.padding(16)
}
.scrollTargetBehavior(.viewAligned)
}
}
}
@@ -310,6 +342,32 @@ struct AllTasksView: View {
private func updateTaskInKanban(_ updatedTask: TaskResponse) {
taskViewModel.updateTaskInKanban(updatedTask)
}
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) {
print("📬 navigateToTaskInKanban called with taskId=\(taskId)")
// Find which column contains the task
for (index, column) in response.columns.enumerated() {
if column.tasks.contains(where: { $0.id == taskId }) {
print("📬 Found task in column \(index) '\(column.name)'")
// Clear pending
pendingTaskId = nil
PushNotificationManager.shared.clearPendingNavigation()
// Small delay to ensure view is ready, then scroll
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.scrollToColumnIndex = index
}
return
}
}
// Task not found
print("📬 Task with id=\(taskId) not found")
pendingTaskId = nil
PushNotificationManager.shared.clearPendingNavigation()
}
}
// Extension to apply corner radius to specific corners

16
iosApp/test_push.apns Normal file
View File

@@ -0,0 +1,16 @@
{
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
"aps": {
"alert": {
"title": "Task Reminder",
"subtitle": "123 Main Street",
"body": "Change HVAC filter is due today"
},
"badge": 1,
"sound": "default",
"category": "TASK_REMINDER"
},
"task_id": "54",
"type": "task_due_soon",
"notification_id": "123"
}