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
|
||||
android:name="com.google.firebase.messaging.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>
|
||||
|
||||
</manifest>
|
||||
@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||
private var navigateToTaskId by mutableStateOf<Int?>(null)
|
||||
private lateinit var billingManager: BillingManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -54,8 +55,9 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||
// Initialize BillingManager for subscription management
|
||||
billingManager = BillingManager.getInstance(applicationContext)
|
||||
|
||||
// Handle deep link from intent
|
||||
// Handle deep link and notification navigation from intent
|
||||
handleDeepLink(intent)
|
||||
handleNotificationNavigation(intent)
|
||||
|
||||
// Request notification permission and setup FCM
|
||||
setupFCM()
|
||||
@@ -68,6 +70,10 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||
deepLinkResetToken = deepLinkResetToken,
|
||||
onClearDeepLinkToken = {
|
||||
deepLinkResetToken = null
|
||||
},
|
||||
navigateToTaskId = navigateToTaskId,
|
||||
onClearNavigateToTask = {
|
||||
navigateToTaskId = null
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -180,6 +186,15 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(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?) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.example.casera.cache.SubscriptionCache
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -92,25 +93,33 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
|
||||
}
|
||||
|
||||
private fun sendNotification(title: String, body: String, data: Map<String, String>) {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
val taskIdStr = data["task_id"]
|
||||
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) ->
|
||||
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) {
|
||||
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_ONE_SHOT
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
val mainPendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
notificationId,
|
||||
mainIntent,
|
||||
pendingIntentFlags
|
||||
)
|
||||
|
||||
@@ -120,16 +129,21 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setContentIntent(mainPendingIntent)
|
||||
.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
|
||||
|
||||
// Create notification channel for Android O and above
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
"MyCrib Notifications",
|
||||
"Casera Notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Notifications for tasks, residences, and warranties"
|
||||
@@ -137,7 +151,60 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
fun App(
|
||||
deepLinkResetToken: String? = null,
|
||||
onClearDeepLinkToken: () -> Unit = {}
|
||||
onClearDeepLinkToken: () -> Unit = {},
|
||||
navigateToTaskId: Int? = null,
|
||||
onClearNavigateToTask: () -> Unit = {}
|
||||
) {
|
||||
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
||||
var isVerified by remember { mutableStateOf(false) }
|
||||
@@ -73,6 +75,15 @@ fun App(
|
||||
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
|
||||
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
|
||||
LaunchedEffect(Unit) {
|
||||
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
|
||||
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
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Register notification categories for actionable notifications
|
||||
NotificationCategories.registerCategories()
|
||||
|
||||
// Request notification permission
|
||||
Task { @MainActor in
|
||||
await PushNotificationManager.shared.requestNotificationPermission()
|
||||
@@ -85,17 +88,33 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
completionHandler([.banner, .sound, .badge])
|
||||
}
|
||||
|
||||
// Called when user taps on notification
|
||||
// Called when user taps on notification or selects an action
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
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
|
||||
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()
|
||||
|
||||
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]) {
|
||||
switch type {
|
||||
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)")
|
||||
// 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":
|
||||
@@ -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 {
|
||||
guard TokenStorage.shared.getToken() != nil,
|
||||
let notificationIdInt = Int32(notificationId) else {
|
||||
|
||||
@@ -17,6 +17,9 @@ struct AllTasksView: View {
|
||||
@State private var selectedTaskForCancel: TaskResponse?
|
||||
@State private var showCancelConfirmation = false
|
||||
|
||||
// Deep link task ID to open (from push notification)
|
||||
@State private var pendingTaskId: Int32?
|
||||
|
||||
// Use ViewModel's computed properties
|
||||
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
|
||||
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
|
||||
@@ -97,6 +100,31 @@ struct AllTasksView: View {
|
||||
loadAllTasks()
|
||||
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
|
||||
|
||||
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