Add actionable push notifications for iOS and Android
iOS: - Add notification categories with action buttons (complete, view, cancel, etc.) - Handle notification actions in AppDelegate with API calls - Add navigation to specific task from notification tap - Register UNNotificationCategory for each task state Android: - Add NotificationActionReceiver BroadcastReceiver for handling actions - Update MyFirebaseMessagingService to show action buttons - Add deep link handling in MainActivity for task navigation - Register receiver in AndroidManifest.xml Shared: - Add navigateToTaskId parameter to App for cross-platform navigation - Add notification observers in MainTabView/AllTasksView for refresh 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,19 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
android:value="@string/default_notification_channel_id" />
|
android:value="@string/default_notification_channel_id" />
|
||||||
|
|
||||||
|
<!-- Notification Action Receiver -->
|
||||||
|
<receiver
|
||||||
|
android:name=".NotificationActionReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.example.casera.ACTION_VIEW_TASK" />
|
||||||
|
<action android:name="com.example.casera.ACTION_COMPLETE_TASK" />
|
||||||
|
<action android:name="com.example.casera.ACTION_MARK_IN_PROGRESS" />
|
||||||
|
<action android:name="com.example.casera.ACTION_CANCEL_TASK" />
|
||||||
|
<action android:name="com.example.casera.ACTION_UNCANCEL_TASK" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||||
|
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
||||||
private lateinit var billingManager: BillingManager
|
private lateinit var billingManager: BillingManager
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -54,8 +55,9 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
// Initialize BillingManager for subscription management
|
// Initialize BillingManager for subscription management
|
||||||
billingManager = BillingManager.getInstance(applicationContext)
|
billingManager = BillingManager.getInstance(applicationContext)
|
||||||
|
|
||||||
// Handle deep link from intent
|
// Handle deep link and notification navigation from intent
|
||||||
handleDeepLink(intent)
|
handleDeepLink(intent)
|
||||||
|
handleNotificationNavigation(intent)
|
||||||
|
|
||||||
// Request notification permission and setup FCM
|
// Request notification permission and setup FCM
|
||||||
setupFCM()
|
setupFCM()
|
||||||
@@ -68,6 +70,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
deepLinkResetToken = deepLinkResetToken,
|
deepLinkResetToken = deepLinkResetToken,
|
||||||
onClearDeepLinkToken = {
|
onClearDeepLinkToken = {
|
||||||
deepLinkResetToken = null
|
deepLinkResetToken = null
|
||||||
|
},
|
||||||
|
navigateToTaskId = navigateToTaskId,
|
||||||
|
onClearNavigateToTask = {
|
||||||
|
navigateToTaskId = null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -180,6 +186,15 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleDeepLink(intent)
|
handleDeepLink(intent)
|
||||||
|
handleNotificationNavigation(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNotificationNavigation(intent: Intent?) {
|
||||||
|
val taskId = intent?.getIntExtra(NotificationActionReceiver.EXTRA_NAVIGATE_TO_TASK, -1)
|
||||||
|
if (taskId != null && taskId != -1) {
|
||||||
|
Log.d("MainActivity", "Navigating to task from notification: $taskId")
|
||||||
|
navigateToTaskId = taskId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDeepLink(intent: Intent?) {
|
private fun handleDeepLink(intent: Intent?) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.example.casera.cache.SubscriptionCache
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -92,25 +93,33 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun sendNotification(title: String, body: String, data: Map<String, String>) {
|
private fun sendNotification(title: String, body: String, data: Map<String, String>) {
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
val taskIdStr = data["task_id"]
|
||||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
val taskId = taskIdStr?.toIntOrNull()
|
||||||
|
val buttonTypesStr = data["button_types"]
|
||||||
|
val notificationId = taskId ?: NOTIFICATION_ID
|
||||||
|
|
||||||
// Add data to intent for handling when notification is clicked
|
// Create main tap intent
|
||||||
|
val mainIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
data.forEach { (key, value) ->
|
data.forEach { (key, value) ->
|
||||||
putExtra(key, value)
|
putExtra(key, value)
|
||||||
}
|
}
|
||||||
|
// Add navigation extra if task notification
|
||||||
|
if (taskId != null) {
|
||||||
|
putExtra(NotificationActionReceiver.EXTRA_NAVIGATE_TO_TASK, taskId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
} else {
|
} else {
|
||||||
PendingIntent.FLAG_ONE_SHOT
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
}
|
}
|
||||||
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val mainPendingIntent = PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
0,
|
notificationId,
|
||||||
intent,
|
mainIntent,
|
||||||
pendingIntentFlags
|
pendingIntentFlags
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,16 +129,21 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(mainPendingIntent)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
|
||||||
|
// Add action buttons for premium users with task notifications
|
||||||
|
if (taskId != null && isPremiumUser()) {
|
||||||
|
addActionButtons(notificationBuilder, taskId, notificationId, buttonTypesStr)
|
||||||
|
}
|
||||||
|
|
||||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
// Create notification channel for Android O and above
|
// Create notification channel for Android O and above
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
channelId,
|
channelId,
|
||||||
"MyCrib Notifications",
|
"Casera Notifications",
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
).apply {
|
).apply {
|
||||||
description = "Notifications for tasks, residences, and warranties"
|
description = "Notifications for tasks, residences, and warranties"
|
||||||
@@ -137,7 +151,60 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
notificationManager.createNotificationChannel(channel)
|
notificationManager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
|
notificationManager.notify(notificationId, notificationBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPremiumUser(): Boolean {
|
||||||
|
val subscription = SubscriptionCache.currentSubscription.value
|
||||||
|
// User is premium if limitations are disabled
|
||||||
|
return subscription?.limitationsEnabled == false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addActionButtons(
|
||||||
|
builder: NotificationCompat.Builder,
|
||||||
|
taskId: Int,
|
||||||
|
notificationId: Int,
|
||||||
|
buttonTypesStr: String?
|
||||||
|
) {
|
||||||
|
val buttonTypes = buttonTypesStr?.split(",")?.map { it.trim() } ?: emptyList()
|
||||||
|
|
||||||
|
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add up to 3 action buttons (Android notification limit)
|
||||||
|
var actionCount = 0
|
||||||
|
val maxActions = 3
|
||||||
|
|
||||||
|
for (buttonType in buttonTypes) {
|
||||||
|
if (actionCount >= maxActions) break
|
||||||
|
|
||||||
|
val (action, label) = when (buttonType) {
|
||||||
|
"complete" -> NotificationActionReceiver.ACTION_COMPLETE_TASK to "Complete"
|
||||||
|
"mark_in_progress" -> NotificationActionReceiver.ACTION_MARK_IN_PROGRESS to "Start"
|
||||||
|
"cancel" -> NotificationActionReceiver.ACTION_CANCEL_TASK to "Cancel"
|
||||||
|
"uncancel" -> NotificationActionReceiver.ACTION_UNCANCEL_TASK to "Restore"
|
||||||
|
else -> continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(this, NotificationActionReceiver::class.java).apply {
|
||||||
|
this.action = action
|
||||||
|
putExtra(NotificationActionReceiver.EXTRA_TASK_ID, taskId)
|
||||||
|
putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
this,
|
||||||
|
notificationId * 10 + actionCount, // Unique request code per action
|
||||||
|
intent,
|
||||||
|
pendingIntentFlags
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.addAction(0, label, pendingIntent)
|
||||||
|
actionCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package com.example.casera
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.example.casera.cache.SubscriptionCache
|
||||||
|
import com.example.casera.models.TaskCompletionCreateRequest
|
||||||
|
import com.example.casera.network.APILayer
|
||||||
|
import com.example.casera.network.ApiResult
|
||||||
|
import com.example.casera.storage.TokenStorage
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BroadcastReceiver for handling notification action button clicks.
|
||||||
|
* Performs task actions (complete, cancel, etc.) directly from notifications.
|
||||||
|
*/
|
||||||
|
class NotificationActionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val action = intent.action ?: return
|
||||||
|
val taskId = intent.getIntExtra(EXTRA_TASK_ID, -1)
|
||||||
|
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
|
||||||
|
|
||||||
|
Log.d(TAG, "Action received: $action for task $taskId")
|
||||||
|
|
||||||
|
if (taskId == -1) {
|
||||||
|
Log.e(TAG, "No task ID provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss the notification
|
||||||
|
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||||
|
|
||||||
|
// Check subscription status
|
||||||
|
val isPremium = isPremiumUser()
|
||||||
|
if (!isPremium) {
|
||||||
|
Log.d(TAG, "Non-premium user, ignoring action")
|
||||||
|
launchMainActivity(context, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle action
|
||||||
|
when (action) {
|
||||||
|
ACTION_VIEW_TASK -> {
|
||||||
|
launchMainActivity(context, taskId)
|
||||||
|
}
|
||||||
|
ACTION_COMPLETE_TASK -> {
|
||||||
|
performCompleteTask(context, taskId)
|
||||||
|
}
|
||||||
|
ACTION_MARK_IN_PROGRESS -> {
|
||||||
|
performMarkInProgress(context, taskId)
|
||||||
|
}
|
||||||
|
ACTION_CANCEL_TASK -> {
|
||||||
|
performCancelTask(context, taskId)
|
||||||
|
}
|
||||||
|
ACTION_UNCANCEL_TASK -> {
|
||||||
|
performUncancelTask(context, taskId)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.w(TAG, "Unknown action: $action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPremiumUser(): Boolean {
|
||||||
|
val subscription = SubscriptionCache.currentSubscription.value
|
||||||
|
// User is premium if limitations are disabled
|
||||||
|
return subscription?.limitationsEnabled == false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchMainActivity(context: Context, taskId: Int?) {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
if (taskId != null) {
|
||||||
|
putExtra(EXTRA_NAVIGATE_TO_TASK, taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performCompleteTask(context: Context, taskId: Int) {
|
||||||
|
Log.d(TAG, "Completing task $taskId")
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val request = TaskCompletionCreateRequest(
|
||||||
|
taskId = taskId,
|
||||||
|
completedAt = null,
|
||||||
|
notes = null,
|
||||||
|
actualCost = null,
|
||||||
|
rating = null,
|
||||||
|
imageUrls = null
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val result = APILayer.createTaskCompletion(request)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
Log.d(TAG, "Task $taskId completed successfully")
|
||||||
|
// Launch app to show result
|
||||||
|
launchMainActivity(context, taskId)
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
Log.e(TAG, "Failed to complete task: ${result.message}")
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performMarkInProgress(context: Context, taskId: Int) {
|
||||||
|
Log.d(TAG, "Marking task $taskId as in progress")
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
when (val result = APILayer.markInProgress(taskId)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
Log.d(TAG, "Task $taskId marked as in progress")
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
Log.e(TAG, "Failed to mark task in progress: ${result.message}")
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performCancelTask(context: Context, taskId: Int) {
|
||||||
|
Log.d(TAG, "Cancelling task $taskId")
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
when (val result = APILayer.cancelTask(taskId)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
Log.d(TAG, "Task $taskId cancelled")
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
Log.e(TAG, "Failed to cancel task: ${result.message}")
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performUncancelTask(context: Context, taskId: Int) {
|
||||||
|
Log.d(TAG, "Uncancelling task $taskId")
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
when (val result = APILayer.uncancelTask(taskId)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
Log.d(TAG, "Task $taskId uncancelled")
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
Log.e(TAG, "Failed to uncancel task: ${result.message}")
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NotificationAction"
|
||||||
|
|
||||||
|
// Action constants
|
||||||
|
const val ACTION_VIEW_TASK = "com.example.casera.ACTION_VIEW_TASK"
|
||||||
|
const val ACTION_COMPLETE_TASK = "com.example.casera.ACTION_COMPLETE_TASK"
|
||||||
|
const val ACTION_MARK_IN_PROGRESS = "com.example.casera.ACTION_MARK_IN_PROGRESS"
|
||||||
|
const val ACTION_CANCEL_TASK = "com.example.casera.ACTION_CANCEL_TASK"
|
||||||
|
const val ACTION_UNCANCEL_TASK = "com.example.casera.ACTION_UNCANCEL_TASK"
|
||||||
|
|
||||||
|
// Extra constants
|
||||||
|
const val EXTRA_TASK_ID = "task_id"
|
||||||
|
const val EXTRA_NOTIFICATION_ID = "notification_id"
|
||||||
|
const val EXTRA_NAVIGATE_TO_TASK = "navigate_to_task"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,9 @@ import casera.composeapp.generated.resources.compose_multiplatform
|
|||||||
@Preview
|
@Preview
|
||||||
fun App(
|
fun App(
|
||||||
deepLinkResetToken: String? = null,
|
deepLinkResetToken: String? = null,
|
||||||
onClearDeepLinkToken: () -> Unit = {}
|
onClearDeepLinkToken: () -> Unit = {},
|
||||||
|
navigateToTaskId: Int? = null,
|
||||||
|
onClearNavigateToTask: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
||||||
var isVerified by remember { mutableStateOf(false) }
|
var isVerified by remember { mutableStateOf(false) }
|
||||||
@@ -73,6 +75,15 @@ fun App(
|
|||||||
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
|
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
// Handle navigation from notification tap
|
||||||
|
LaunchedEffect(navigateToTaskId) {
|
||||||
|
if (navigateToTaskId != null && isLoggedIn && isVerified) {
|
||||||
|
// Navigate to tasks screen (task detail view is handled within the screen)
|
||||||
|
navController.navigate(TasksRoute)
|
||||||
|
onClearNavigateToTask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for stored token and verification status on app start
|
// Check for stored token and verification status on app start
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val hasToken = DataManager.authToken.value != null
|
val hasToken = DataManager.authToken.value != null
|
||||||
|
|||||||
11
iosApp/iosApp/Extensions/Notification+Names.swift
Normal file
11
iosApp/iosApp/Extensions/Notification+Names.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
// Navigation notifications from push notification actions
|
||||||
|
static let navigateToTask = Notification.Name("navigateToTask")
|
||||||
|
static let navigateToEditTask = Notification.Name("navigateToEditTask")
|
||||||
|
static let navigateToHome = Notification.Name("navigateToHome")
|
||||||
|
|
||||||
|
// Task action completion notification (for UI refresh)
|
||||||
|
static let taskActionCompleted = Notification.Name("taskActionCompleted")
|
||||||
|
}
|
||||||
@@ -62,6 +62,17 @@ struct MainTabView: View {
|
|||||||
.onChange(of: authManager.isAuthenticated) { _ in
|
.onChange(of: authManager.isAuthenticated) { _ in
|
||||||
selectedTab = 0
|
selectedTab = 0
|
||||||
}
|
}
|
||||||
|
// Handle push notification deep links - switch to appropriate tab
|
||||||
|
// The actual task navigation is handled by AllTasksView
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
|
||||||
|
selectedTab = 1 // Switch to Tasks tab
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
|
||||||
|
selectedTab = 1 // Switch to Tasks tab
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
|
||||||
|
selectedTab = 0 // Switch to Residences tab
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
// Set notification delegate
|
// Set notification delegate
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
|
||||||
|
// Register notification categories for actionable notifications
|
||||||
|
NotificationCategories.registerCategories()
|
||||||
|
|
||||||
// Request notification permission
|
// Request notification permission
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await PushNotificationManager.shared.requestNotificationPermission()
|
await PushNotificationManager.shared.requestNotificationPermission()
|
||||||
@@ -85,17 +88,33 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
completionHandler([.banner, .sound, .badge])
|
completionHandler([.banner, .sound, .badge])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called when user taps on notification
|
// Called when user taps on notification or selects an action
|
||||||
func userNotificationCenter(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
print("👆 User tapped notification: \(userInfo)")
|
let actionIdentifier = response.actionIdentifier
|
||||||
|
|
||||||
|
print("👆 User interacted with notification - Action: \(actionIdentifier)")
|
||||||
|
print(" UserInfo: \(userInfo)")
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
PushNotificationManager.shared.handleNotification(userInfo: userInfo)
|
// Handle action buttons or default tap
|
||||||
|
if actionIdentifier == UNNotificationDefaultActionIdentifier {
|
||||||
|
// User tapped the notification body - navigate to task
|
||||||
|
PushNotificationManager.shared.handleNotificationTap(userInfo: userInfo)
|
||||||
|
} else if actionIdentifier == UNNotificationDismissActionIdentifier {
|
||||||
|
// User dismissed the notification
|
||||||
|
print("📤 Notification dismissed")
|
||||||
|
} else {
|
||||||
|
// User selected an action button
|
||||||
|
PushNotificationManager.shared.handleNotificationAction(
|
||||||
|
actionIdentifier: actionIdentifier,
|
||||||
|
userInfo: userInfo
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
|
|||||||
140
iosApp/iosApp/PushNotifications/NotificationCategories.swift
Normal file
140
iosApp/iosApp/PushNotifications/NotificationCategories.swift
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Notification Category Identifiers matching backend constants
|
||||||
|
enum NotificationCategoryID: String {
|
||||||
|
case taskActionable = "TASK_ACTIONABLE" // overdue, due_soon, upcoming
|
||||||
|
case taskInProgress = "TASK_IN_PROGRESS" // tasks in progress
|
||||||
|
case taskCancelled = "TASK_CANCELLED" // cancelled tasks
|
||||||
|
case taskCompleted = "TASK_COMPLETED" // completed tasks (read-only)
|
||||||
|
case taskGeneric = "TASK_NOTIFICATION_GENERIC" // non-premium users
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action Identifiers for notification buttons
|
||||||
|
enum NotificationActionID: String {
|
||||||
|
// Task actions
|
||||||
|
case viewTask = "VIEW_TASK"
|
||||||
|
case completeTask = "COMPLETE_TASK"
|
||||||
|
case markInProgress = "MARK_IN_PROGRESS"
|
||||||
|
case cancelTask = "CANCEL_TASK"
|
||||||
|
case uncancelTask = "UNCANCEL_TASK"
|
||||||
|
case editTask = "EDIT_TASK"
|
||||||
|
|
||||||
|
// Default action (tapped notification body)
|
||||||
|
case defaultAction = "com.apple.UNNotificationDefaultActionIdentifier"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages notification category registration
|
||||||
|
struct NotificationCategories {
|
||||||
|
|
||||||
|
/// Registers all notification categories with the system
|
||||||
|
/// Call this early in app launch (didFinishLaunching)
|
||||||
|
static func registerCategories() {
|
||||||
|
let categories = createAllCategories()
|
||||||
|
UNUserNotificationCenter.current().setNotificationCategories(categories)
|
||||||
|
print("Registered \(categories.count) notification categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates all notification categories for the app
|
||||||
|
private static func createAllCategories() -> Set<UNNotificationCategory> {
|
||||||
|
return [
|
||||||
|
createTaskActionableCategory(),
|
||||||
|
createTaskInProgressCategory(),
|
||||||
|
createTaskCancelledCategory(),
|
||||||
|
createTaskCompletedCategory(),
|
||||||
|
createTaskGenericCategory()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category Definitions
|
||||||
|
|
||||||
|
/// TASK_ACTIONABLE: For overdue, due_soon, upcoming tasks
|
||||||
|
/// Actions: Complete, Mark In Progress, Cancel
|
||||||
|
private static func createTaskActionableCategory() -> UNNotificationCategory {
|
||||||
|
let completeAction = UNNotificationAction(
|
||||||
|
identifier: NotificationActionID.completeTask.rawValue,
|
||||||
|
title: "Complete",
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
let inProgressAction = UNNotificationAction(
|
||||||
|
identifier: NotificationActionID.markInProgress.rawValue,
|
||||||
|
title: "Start",
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
let cancelAction = UNNotificationAction(
|
||||||
|
identifier: NotificationActionID.cancelTask.rawValue,
|
||||||
|
title: "Cancel",
|
||||||
|
options: [.destructive]
|
||||||
|
)
|
||||||
|
|
||||||
|
return UNNotificationCategory(
|
||||||
|
identifier: NotificationCategoryID.taskActionable.rawValue,
|
||||||
|
actions: [completeAction, inProgressAction, cancelAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TASK_IN_PROGRESS: For tasks currently being worked on
|
||||||
|
/// Actions: Complete, Cancel
|
||||||
|
private static func createTaskInProgressCategory() -> UNNotificationCategory {
|
||||||
|
let completeAction = UNNotificationAction(
|
||||||
|
identifier: NotificationActionID.completeTask.rawValue,
|
||||||
|
title: "Complete",
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
let cancelAction = UNNotificationAction(
|
||||||
|
identifier: NotificationActionID.cancelTask.rawValue,
|
||||||
|
title: "Cancel",
|
||||||
|
options: [.destructive]
|
||||||
|
)
|
||||||
|
|
||||||
|
return UNNotificationCategory(
|
||||||
|
identifier: NotificationCategoryID.taskInProgress.rawValue,
|
||||||
|
actions: [completeAction, cancelAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TASK_CANCELLED: For cancelled tasks
|
||||||
|
/// Actions: Uncancel
|
||||||
|
private static func createTaskCancelledCategory() -> UNNotificationCategory {
|
||||||
|
let uncancelAction = UNNotificationAction(
|
||||||
|
identifier: NotificationActionID.uncancelTask.rawValue,
|
||||||
|
title: "Restore",
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
return UNNotificationCategory(
|
||||||
|
identifier: NotificationCategoryID.taskCancelled.rawValue,
|
||||||
|
actions: [uncancelAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TASK_COMPLETED: For completed tasks (read-only, tap to view)
|
||||||
|
/// No actions - just view on tap
|
||||||
|
private static func createTaskCompletedCategory() -> UNNotificationCategory {
|
||||||
|
return UNNotificationCategory(
|
||||||
|
identifier: NotificationCategoryID.taskCompleted.rawValue,
|
||||||
|
actions: [],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TASK_NOTIFICATION_GENERIC: For non-premium users
|
||||||
|
/// No actions - tap opens app home
|
||||||
|
private static func createTaskGenericCategory() -> UNNotificationCategory {
|
||||||
|
return UNNotificationCategory(
|
||||||
|
identifier: NotificationCategoryID.taskGeneric.rawValue,
|
||||||
|
actions: [],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,12 +163,103 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Called when user taps the notification body (not an action button)
|
||||||
|
func handleNotificationTap(userInfo: [AnyHashable: Any]) {
|
||||||
|
print("📬 Handling notification tap")
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
if let notificationId = userInfo["notification_id"] as? String {
|
||||||
|
Task {
|
||||||
|
await markNotificationAsRead(notificationId: notificationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subscription status to determine navigation
|
||||||
|
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" ||
|
||||||
|
!(SubscriptionCacheWrapper.shared.currentSubscription?.limitationsEnabled ?? true)
|
||||||
|
|
||||||
|
if isPremium {
|
||||||
|
// Premium user - 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
|
||||||
|
navigateToHome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when user selects an action button on the notification
|
||||||
|
func handleNotificationAction(actionIdentifier: String, userInfo: [AnyHashable: Any]) {
|
||||||
|
print("🔘 Handling notification action: \(actionIdentifier)")
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
if let notificationId = userInfo["notification_id"] as? String {
|
||||||
|
Task {
|
||||||
|
await markNotificationAsRead(notificationId: notificationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check subscription status
|
||||||
|
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" ||
|
||||||
|
!(SubscriptionCacheWrapper.shared.currentSubscription?.limitationsEnabled ?? true)
|
||||||
|
|
||||||
|
guard isPremium else {
|
||||||
|
// Free user shouldn't see actions, but if they somehow do, go to home
|
||||||
|
navigateToHome()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract task ID
|
||||||
|
var taskId: Int?
|
||||||
|
if let taskIdStr = userInfo["task_id"] as? String {
|
||||||
|
taskId = Int(taskIdStr)
|
||||||
|
} else if let id = userInfo["task_id"] as? Int {
|
||||||
|
taskId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let taskId = taskId else {
|
||||||
|
print("❌ No task_id found in notification")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle action
|
||||||
|
switch actionIdentifier {
|
||||||
|
case NotificationActionID.viewTask.rawValue:
|
||||||
|
navigateToTask(taskId: taskId)
|
||||||
|
|
||||||
|
case NotificationActionID.completeTask.rawValue:
|
||||||
|
performCompleteTask(taskId: taskId)
|
||||||
|
|
||||||
|
case NotificationActionID.markInProgress.rawValue:
|
||||||
|
performMarkInProgress(taskId: taskId)
|
||||||
|
|
||||||
|
case NotificationActionID.cancelTask.rawValue:
|
||||||
|
performCancelTask(taskId: taskId)
|
||||||
|
|
||||||
|
case NotificationActionID.uncancelTask.rawValue:
|
||||||
|
performUncancelTask(taskId: taskId)
|
||||||
|
|
||||||
|
case NotificationActionID.editTask.rawValue:
|
||||||
|
navigateToEditTask(taskId: taskId)
|
||||||
|
|
||||||
|
default:
|
||||||
|
print("⚠️ Unknown action: \(actionIdentifier)")
|
||||||
|
navigateToTask(taskId: taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleNotificationType(type: String, userInfo: [AnyHashable: Any]) {
|
private func handleNotificationType(type: String, userInfo: [AnyHashable: Any]) {
|
||||||
switch type {
|
switch type {
|
||||||
case "task_due_soon", "task_overdue", "task_completed", "task_assigned":
|
case "task_due_soon", "task_overdue", "task_completed", "task_assigned":
|
||||||
if let taskId = userInfo["task_id"] as? String {
|
if let taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) {
|
||||||
print("Task notification for task ID: \(taskId)")
|
print("Task notification for task ID: \(taskId)")
|
||||||
// TODO: Navigate to task detail
|
navigateToTask(taskId: taskId)
|
||||||
|
} else if let taskId = userInfo["task_id"] as? Int {
|
||||||
|
print("Task notification for task ID: \(taskId)")
|
||||||
|
navigateToTask(taskId: taskId)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "residence_shared":
|
case "residence_shared":
|
||||||
@@ -188,6 +279,123 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Task Actions
|
||||||
|
|
||||||
|
private func performCompleteTask(taskId: Int) {
|
||||||
|
print("✅ Completing task \(taskId) from notification action")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
// Quick complete without photos/notes
|
||||||
|
let request = TaskCompletionCreateRequest(
|
||||||
|
taskId: Int32(taskId),
|
||||||
|
completedAt: nil,
|
||||||
|
notes: nil,
|
||||||
|
actualCost: nil,
|
||||||
|
rating: nil,
|
||||||
|
imageUrls: nil
|
||||||
|
)
|
||||||
|
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskCompletionResponse> {
|
||||||
|
print("✅ Task \(taskId) completed successfully")
|
||||||
|
// Post notification for UI refresh
|
||||||
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||||
|
}
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
print("❌ Failed to complete task: \(error.message)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("❌ Error completing task: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performMarkInProgress(taskId: Int) {
|
||||||
|
print("🔄 Marking task \(taskId) as in progress from notification action")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.markInProgress(taskId: Int32(taskId))
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
|
print("✅ Task \(taskId) marked as in progress")
|
||||||
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||||
|
}
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
print("❌ Failed to mark task in progress: \(error.message)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("❌ Error marking task in progress: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performCancelTask(taskId: Int) {
|
||||||
|
print("🚫 Cancelling task \(taskId) from notification action")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.cancelTask(taskId: Int32(taskId))
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
|
print("✅ Task \(taskId) cancelled")
|
||||||
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||||
|
}
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
print("❌ Failed to cancel task: \(error.message)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("❌ Error cancelling task: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performUncancelTask(taskId: Int) {
|
||||||
|
print("↩️ Uncancelling task \(taskId) from notification action")
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.uncancelTask(taskId: Int32(taskId))
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
|
print("✅ Task \(taskId) uncancelled")
|
||||||
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||||
|
}
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
print("❌ Failed to uncancel task: \(error.message)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("❌ Error uncancelling task: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation
|
||||||
|
|
||||||
|
private func navigateToTask(taskId: Int) {
|
||||||
|
print("📱 Navigating to task \(taskId)")
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .navigateToTask,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["taskId": taskId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigateToEditTask(taskId: Int) {
|
||||||
|
print("✏️ Navigating to edit task \(taskId)")
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .navigateToEditTask,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["taskId": taskId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigateToHome() {
|
||||||
|
print("🏠 Navigating to home")
|
||||||
|
NotificationCenter.default.post(name: .navigateToHome, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
private func markNotificationAsRead(notificationId: String) async {
|
private func markNotificationAsRead(notificationId: String) async {
|
||||||
guard TokenStorage.shared.getToken() != nil,
|
guard TokenStorage.shared.getToken() != nil,
|
||||||
let notificationIdInt = Int32(notificationId) else {
|
let notificationIdInt = Int32(notificationId) else {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ struct AllTasksView: View {
|
|||||||
@State private var selectedTaskForCancel: TaskResponse?
|
@State private var selectedTaskForCancel: TaskResponse?
|
||||||
@State private var showCancelConfirmation = false
|
@State private var showCancelConfirmation = false
|
||||||
|
|
||||||
|
// Deep link task ID to open (from push notification)
|
||||||
|
@State private var pendingTaskId: Int32?
|
||||||
|
|
||||||
// Use ViewModel's computed properties
|
// Use ViewModel's computed properties
|
||||||
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
|
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
|
||||||
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
|
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
|
||||||
@@ -97,6 +100,31 @@ struct AllTasksView: View {
|
|||||||
loadAllTasks()
|
loadAllTasks()
|
||||||
residenceViewModel.loadMyResidences()
|
residenceViewModel.loadMyResidences()
|
||||||
}
|
}
|
||||||
|
// Handle push notification deep links
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in
|
||||||
|
if let userInfo = notification.userInfo,
|
||||||
|
let taskId = userInfo["taskId"] as? Int {
|
||||||
|
pendingTaskId = Int32(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in
|
||||||
|
if let userInfo = notification.userInfo,
|
||||||
|
let taskId = userInfo["taskId"] as? Int {
|
||||||
|
pendingTaskId = Int32(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// When tasks load and we have a pending task ID, open the edit sheet
|
||||||
|
.onChange(of: tasksResponse) { response in
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
81
iosApp/push_test_payloads/README.md
Normal file
81
iosApp/push_test_payloads/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Push Notification Test Payloads
|
||||||
|
|
||||||
|
These `.apns` files can be dragged onto the iOS Simulator to test push notifications with action buttons.
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
1. Run the app on the iOS Simulator
|
||||||
|
2. Drag and drop any `.apns` file onto the Simulator window
|
||||||
|
3. The notification will appear with the appropriate action buttons
|
||||||
|
|
||||||
|
## Before Testing
|
||||||
|
|
||||||
|
Edit the `task_id` in each file to match a valid task ID from your database.
|
||||||
|
|
||||||
|
## Available Payloads
|
||||||
|
|
||||||
|
| File | Category | Actions | Description |
|
||||||
|
|------|----------|---------|-------------|
|
||||||
|
| `task_overdue.apns` | TASK_ACTIONABLE | edit, complete, cancel, mark_in_progress | Overdue task notification |
|
||||||
|
| `task_due_soon.apns` | TASK_ACTIONABLE | edit, complete, cancel, mark_in_progress | Task due within threshold |
|
||||||
|
| `task_in_progress.apns` | TASK_IN_PROGRESS | edit, complete, cancel | Task being worked on |
|
||||||
|
| `task_cancelled.apns` | TASK_CANCELLED | uncancel, delete | Cancelled task |
|
||||||
|
| `task_completed.apns` | TASK_COMPLETED | (none - read only) | Completed one-time task |
|
||||||
|
| `task_assigned.apns` | TASK_ACTIONABLE | edit, complete, cancel, mark_in_progress | Task assigned to user |
|
||||||
|
| `task_generic_free_user.apns` | TASK_NOTIFICATION_GENERIC | (none) | Free user sees generic message |
|
||||||
|
|
||||||
|
## iOS Notification Categories
|
||||||
|
|
||||||
|
The `category` field in `aps` maps to these iOS notification categories:
|
||||||
|
|
||||||
|
- `TASK_ACTIONABLE` - For overdue, due_soon, upcoming tasks (full action set)
|
||||||
|
- `TASK_IN_PROGRESS` - For tasks currently being worked on
|
||||||
|
- `TASK_CANCELLED` - For cancelled tasks (can uncancel or delete)
|
||||||
|
- `TASK_COMPLETED` - For completed one-time tasks (read-only, no actions)
|
||||||
|
- `TASK_NOTIFICATION_GENERIC` - For free users (no actions, generic message)
|
||||||
|
|
||||||
|
## Payload Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "Notification Title",
|
||||||
|
"subtitle": "Residence Name",
|
||||||
|
"body": "Notification body text"
|
||||||
|
},
|
||||||
|
"sound": "default",
|
||||||
|
"badge": 1,
|
||||||
|
"category": "TASK_ACTIONABLE",
|
||||||
|
"mutable-content": 1,
|
||||||
|
"thread-id": "task-{id}"
|
||||||
|
},
|
||||||
|
"task_id": 123,
|
||||||
|
"task_name": "Task Name",
|
||||||
|
"residence_id": 1,
|
||||||
|
"residence_name": "Residence Name",
|
||||||
|
"notification_type": "task_overdue|task_due_soon|task_completed|task_assigned",
|
||||||
|
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
|
||||||
|
"is_premium": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Data Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `task_id` | int | The task ID to navigate to when tapped |
|
||||||
|
| `task_name` | string | Display name of the task |
|
||||||
|
| `residence_id` | int | The residence ID |
|
||||||
|
| `residence_name` | string | Display name of the residence |
|
||||||
|
| `notification_type` | string | Type of notification (for analytics/logging) |
|
||||||
|
| `button_types` | array | Available actions for this task state |
|
||||||
|
| `is_premium` | bool | Whether user is premium (affects display) |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The `Simulator Target Bundle` must match your app's bundle identifier
|
||||||
|
- `mutable-content: 1` allows the Notification Service Extension to modify content
|
||||||
|
- `thread-id` groups related notifications together
|
||||||
|
- The app must register notification categories on launch for actions to work
|
||||||
22
iosApp/push_test_payloads/task_assigned.apns
Normal file
22
iosApp/push_test_payloads/task_assigned.apns
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "Task Assigned",
|
||||||
|
"subtitle": "Test Residence",
|
||||||
|
"body": "You have been assigned to: Winterize Sprinklers"
|
||||||
|
},
|
||||||
|
"sound": "default",
|
||||||
|
"badge": 1,
|
||||||
|
"category": "TASK_ACTIONABLE",
|
||||||
|
"mutable-content": 1,
|
||||||
|
"thread-id": "task-39"
|
||||||
|
},
|
||||||
|
"task_id": 39,
|
||||||
|
"task_name": "Winterize Sprinklers",
|
||||||
|
"residence_id": 1,
|
||||||
|
"residence_name": "Test Residence",
|
||||||
|
"notification_type": "task_assigned",
|
||||||
|
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
|
||||||
|
"is_premium": true
|
||||||
|
}
|
||||||
22
iosApp/push_test_payloads/task_cancelled.apns
Normal file
22
iosApp/push_test_payloads/task_cancelled.apns
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "Task Cancelled",
|
||||||
|
"subtitle": "Test Residence",
|
||||||
|
"body": "Paint Fence has been cancelled"
|
||||||
|
},
|
||||||
|
"sound": "default",
|
||||||
|
"badge": 1,
|
||||||
|
"category": "TASK_CANCELLED",
|
||||||
|
"mutable-content": 1,
|
||||||
|
"thread-id": "task-101"
|
||||||
|
},
|
||||||
|
"task_id": 101,
|
||||||
|
"task_name": "Paint Fence",
|
||||||
|
"residence_id": 1,
|
||||||
|
"residence_name": "Test Residence",
|
||||||
|
"notification_type": "task_cancelled",
|
||||||
|
"button_types": ["uncancel", "delete"],
|
||||||
|
"is_premium": true
|
||||||
|
}
|
||||||
22
iosApp/push_test_payloads/task_completed.apns
Normal file
22
iosApp/push_test_payloads/task_completed.apns
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "Task Completed",
|
||||||
|
"subtitle": "Test Residence",
|
||||||
|
"body": "Fix Leaky Faucet was completed by John"
|
||||||
|
},
|
||||||
|
"sound": "default",
|
||||||
|
"badge": 1,
|
||||||
|
"category": "TASK_COMPLETED",
|
||||||
|
"mutable-content": 1,
|
||||||
|
"thread-id": "task-202"
|
||||||
|
},
|
||||||
|
"task_id": 202,
|
||||||
|
"task_name": "Fix Leaky Faucet",
|
||||||
|
"residence_id": 1,
|
||||||
|
"residence_name": "Test Residence",
|
||||||
|
"notification_type": "task_completed",
|
||||||
|
"button_types": [],
|
||||||
|
"is_premium": true
|
||||||
|
}
|
||||||
22
iosApp/push_test_payloads/task_due_soon.apns
Normal file
22
iosApp/push_test_payloads/task_due_soon.apns
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "Task Due Soon",
|
||||||
|
"subtitle": "Test Residence",
|
||||||
|
"body": "Clean Gutters is due in 5 days"
|
||||||
|
},
|
||||||
|
"sound": "default",
|
||||||
|
"badge": 1,
|
||||||
|
"category": "TASK_ACTIONABLE",
|
||||||
|
"mutable-content": 1,
|
||||||
|
"thread-id": "task-12"
|
||||||
|
},
|
||||||
|
"task_id": 12,
|
||||||
|
"task_name": "Clean Gutters",
|
||||||
|
"residence_id": 1,
|
||||||
|
"residence_name": "Test Residence",
|
||||||
|
"notification_type": "task_due_soon",
|
||||||
|
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
|
||||||
|
"is_premium": true
|
||||||
|
}
|
||||||
20
iosApp/push_test_payloads/task_generic_free_user.apns
Normal file
20
iosApp/push_test_payloads/task_generic_free_user.apns
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "Casera",
|
||||||
|
"body": "Something in Casera needs your attention"
|
||||||
|
},
|
||||||
|
"sound": "default",
|
||||||
|
"badge": 1,
|
||||||
|
"category": "TASK_NOTIFICATION_GENERIC",
|
||||||
|
"mutable-content": 1
|
||||||
|
},
|
||||||
|
"task_id": 303,
|
||||||
|
"task_name": "Secret Task Name",
|
||||||
|
"residence_id": 1,
|
||||||
|
"residence_name": "Test Residence",
|
||||||
|
"notification_type": "task_overdue",
|
||||||
|
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
|
||||||
|
"is_premium": false
|
||||||
|
}
|
||||||
22
iosApp/push_test_payloads/task_in_progress.apns
Normal file
22
iosApp/push_test_payloads/task_in_progress.apns
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "Task In Progress",
|
||||||
|
"subtitle": "Test Residence",
|
||||||
|
"body": "Repair Deck Railing is being worked on"
|
||||||
|
},
|
||||||
|
"sound": "default",
|
||||||
|
"badge": 1,
|
||||||
|
"category": "TASK_IN_PROGRESS",
|
||||||
|
"mutable-content": 1,
|
||||||
|
"thread-id": "task-789"
|
||||||
|
},
|
||||||
|
"task_id": 789,
|
||||||
|
"task_name": "Repair Deck Railing",
|
||||||
|
"residence_id": 1,
|
||||||
|
"residence_name": "Test Residence",
|
||||||
|
"notification_type": "task_assigned",
|
||||||
|
"button_types": ["edit", "complete", "cancel"],
|
||||||
|
"is_premium": true
|
||||||
|
}
|
||||||
22
iosApp/push_test_payloads/task_overdue.apns
Normal file
22
iosApp/push_test_payloads/task_overdue.apns
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"Simulator Target Bundle": "com.tt.casera.CaseraDev",
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "Task Overdue",
|
||||||
|
"subtitle": "Test Residence",
|
||||||
|
"body": "Replace HVAC Filter is overdue"
|
||||||
|
},
|
||||||
|
"sound": "default",
|
||||||
|
"badge": 1,
|
||||||
|
"category": "TASK_ACTIONABLE",
|
||||||
|
"mutable-content": 1,
|
||||||
|
"thread-id": "task-38"
|
||||||
|
},
|
||||||
|
"task_id": 38,
|
||||||
|
"task_name": "Replace HVAC Filter",
|
||||||
|
"residence_id": 1,
|
||||||
|
"residence_name": "Test Residence",
|
||||||
|
"notification_type": "task_overdue",
|
||||||
|
"button_types": ["edit", "complete", "cancel", "mark_in_progress"],
|
||||||
|
"is_premium": true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user