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

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