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:
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
16
iosApp/test_push.apns
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user