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

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