Add push notification support for Android and iOS
- Integrated Firebase Cloud Messaging (FCM) for Android - Integrated Apple Push Notification Service (APNs) for iOS - Created shared notification models and API client - Added device registration and token management - Added notification permission handling for Android - Created PushNotificationManager for iOS with AppDelegate - Added placeholder google-services.json (needs to be replaced with actual config) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,4 +7,5 @@ plugins {
|
|||||||
alias(libs.plugins.composeMultiplatform) apply false
|
alias(libs.plugins.composeMultiplatform) apply false
|
||||||
alias(libs.plugins.composeCompiler) apply false
|
alias(libs.plugins.composeCompiler) apply false
|
||||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||||
|
alias(libs.plugins.googleServices) apply false
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import org.jetbrains.kotlin.gradle.plugin.KotlinDependencyHandler
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
@@ -9,6 +10,7 @@ plugins {
|
|||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
alias(libs.plugins.composeHotReload)
|
alias(libs.plugins.composeHotReload)
|
||||||
alias(libs.plugins.kotlinxSerialization)
|
alias(libs.plugins.kotlinxSerialization)
|
||||||
|
alias(libs.plugins.googleServices)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -120,6 +122,10 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
debugImplementation(compose.uiTooling)
|
debugImplementation(compose.uiTooling)
|
||||||
|
|
||||||
|
// Firebase Cloud Messaging - use specific version for KMM compatibility
|
||||||
|
implementation("com.google.firebase:firebase-messaging-ktx:24.1.0")
|
||||||
|
implementation("com.google.firebase:firebase-bom:34.0.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
|
|||||||
29
composeApp/google-services.json
Normal file
29
composeApp/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "YOUR_PROJECT_NUMBER",
|
||||||
|
"project_id": "your-project-id",
|
||||||
|
"storage_bucket": "your-project-id.appspot.com"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:YOUR_PROJECT_NUMBER:android:YOUR_APP_ID",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.example.mycrib"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "YOUR_API_KEY"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
@@ -50,6 +51,20 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<!-- Firebase Cloud Messaging Service -->
|
||||||
|
<service
|
||||||
|
android:name=".MyFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- Default notification channel ID -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
|
android:value="@string/default_notification_channel_id" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -3,6 +3,7 @@ package com.example.mycrib
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -11,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.PlatformContext
|
import coil3.PlatformContext
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
@@ -24,6 +26,8 @@ import com.mycrib.storage.TokenManager
|
|||||||
import com.mycrib.storage.TokenStorage
|
import com.mycrib.storage.TokenStorage
|
||||||
import com.mycrib.storage.TaskCacheManager
|
import com.mycrib.storage.TaskCacheManager
|
||||||
import com.mycrib.storage.TaskCacheStorage
|
import com.mycrib.storage.TaskCacheStorage
|
||||||
|
import com.example.mycrib.fcm.FCMManager
|
||||||
|
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)
|
||||||
@@ -41,6 +45,9 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
// Handle deep link from intent
|
// Handle deep link from intent
|
||||||
handleDeepLink(intent)
|
handleDeepLink(intent)
|
||||||
|
|
||||||
|
// Request notification permission and setup FCM
|
||||||
|
setupFCM()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
deepLinkResetToken = deepLinkResetToken,
|
deepLinkResetToken = deepLinkResetToken,
|
||||||
@@ -51,6 +58,74 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setupFCM() {
|
||||||
|
// Request notification permission if needed
|
||||||
|
if (!FCMManager.isNotificationPermissionGranted(this)) {
|
||||||
|
FCMManager.requestNotificationPermission(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get FCM token and register with backend
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val fcmToken = FCMManager.getFCMToken()
|
||||||
|
if (fcmToken != null) {
|
||||||
|
Log.d("MainActivity", "FCM Token: $fcmToken")
|
||||||
|
registerDeviceWithBackend(fcmToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun registerDeviceWithBackend(fcmToken: String) {
|
||||||
|
try {
|
||||||
|
val authToken = TokenStorage.getToken()
|
||||||
|
if (authToken != null) {
|
||||||
|
val notificationApi = com.mycrib.shared.network.NotificationApi()
|
||||||
|
val request = com.mycrib.shared.models.DeviceRegistrationRequest(
|
||||||
|
registrationId = fcmToken,
|
||||||
|
platform = "android"
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val result = notificationApi.registerDevice(authToken, request)) {
|
||||||
|
is com.mycrib.shared.network.ApiResult.Success -> {
|
||||||
|
Log.d("MainActivity", "Device registered successfully: ${result.data}")
|
||||||
|
}
|
||||||
|
is com.mycrib.shared.network.ApiResult.Error -> {
|
||||||
|
Log.e("MainActivity", "Failed to register device: ${result.message}")
|
||||||
|
}
|
||||||
|
is com.mycrib.shared.network.ApiResult.Loading,
|
||||||
|
is com.mycrib.shared.network.ApiResult.Idle -> {
|
||||||
|
// These states shouldn't occur for direct API calls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d("MainActivity", "No auth token available, will register device after login")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("MainActivity", "Error registering device", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
when (requestCode) {
|
||||||
|
FCMManager.NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Log.d("MainActivity", "Notification permission granted")
|
||||||
|
// Get FCM token now that permission is granted
|
||||||
|
lifecycleScope.launch {
|
||||||
|
FCMManager.getFCMToken()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d("MainActivity", "Notification permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleDeepLink(intent)
|
handleDeepLink(intent)
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.example.mycrib
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class MyFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
Log.d(TAG, "New FCM token: $token")
|
||||||
|
|
||||||
|
// Store token locally for registration with backend
|
||||||
|
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(KEY_FCM_TOKEN, token)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
// Send token to backend API if user is logged in
|
||||||
|
// Note: In a real app, you might want to use WorkManager for reliable delivery
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val authToken = com.mycrib.storage.TokenStorage.getToken()
|
||||||
|
if (authToken != null) {
|
||||||
|
val notificationApi = com.mycrib.shared.network.NotificationApi()
|
||||||
|
val request = com.mycrib.shared.models.DeviceRegistrationRequest(
|
||||||
|
registrationId = token,
|
||||||
|
platform = "android"
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val result = notificationApi.registerDevice(authToken, request)) {
|
||||||
|
is com.mycrib.shared.network.ApiResult.Success -> {
|
||||||
|
Log.d(TAG, "Device registered successfully with new token")
|
||||||
|
}
|
||||||
|
is com.mycrib.shared.network.ApiResult.Error -> {
|
||||||
|
Log.e(TAG, "Failed to register device with new token: ${result.message}")
|
||||||
|
}
|
||||||
|
is com.mycrib.shared.network.ApiResult.Loading,
|
||||||
|
is com.mycrib.shared.network.ApiResult.Idle -> {
|
||||||
|
// These states shouldn't occur for direct API calls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error registering device with new token", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
super.onMessageReceived(message)
|
||||||
|
|
||||||
|
Log.d(TAG, "Message received from: ${message.from}")
|
||||||
|
|
||||||
|
// Check if message contains notification payload
|
||||||
|
message.notification?.let { notification ->
|
||||||
|
Log.d(TAG, "Notification: ${notification.title} - ${notification.body}")
|
||||||
|
sendNotification(
|
||||||
|
notification.title ?: "MyCrib",
|
||||||
|
notification.body ?: "",
|
||||||
|
message.data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if message contains data payload
|
||||||
|
if (message.data.isNotEmpty()) {
|
||||||
|
Log.d(TAG, "Message data: ${message.data}")
|
||||||
|
|
||||||
|
// If there's no notification payload, create one from data
|
||||||
|
if (message.notification == null) {
|
||||||
|
val title = message.data["title"] ?: "MyCrib"
|
||||||
|
val body = message.data["body"] ?: ""
|
||||||
|
sendNotification(title, body, message.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Add data to intent for handling when notification is clicked
|
||||||
|
data.forEach { (key, value) ->
|
||||||
|
putExtra(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_ONE_SHOT
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
pendingIntentFlags
|
||||||
|
)
|
||||||
|
|
||||||
|
val channelId = getString(R.string.default_notification_channel_id)
|
||||||
|
val notificationBuilder = NotificationCompat.Builder(this, channelId)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
|
||||||
|
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",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Notifications for tasks, residences, and warranties"
|
||||||
|
}
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FCMService"
|
||||||
|
private const val NOTIFICATION_ID = 0
|
||||||
|
private const val PREFS_NAME = "mycrib_prefs"
|
||||||
|
private const val KEY_FCM_TOKEN = "fcm_token"
|
||||||
|
|
||||||
|
fun getStoredToken(context: Context): String? {
|
||||||
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.getString(KEY_FCM_TOKEN, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.example.mycrib.fcm
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.android.gms.tasks.Task
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
|
||||||
|
object FCMManager {
|
||||||
|
private const val TAG = "FCMManager"
|
||||||
|
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1001
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if notification permission is granted (Android 13+)
|
||||||
|
*/
|
||||||
|
fun isNotificationPermissionGranted(context: Context): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
} else {
|
||||||
|
// Permission automatically granted on older versions
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request notification permission (Android 13+)
|
||||||
|
*/
|
||||||
|
fun requestNotificationPermission(activity: Activity) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
activity,
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get FCM token
|
||||||
|
*/
|
||||||
|
suspend fun getFCMToken(): String? {
|
||||||
|
return try {
|
||||||
|
val token = FirebaseMessaging.getInstance().token.await()
|
||||||
|
Log.d(TAG, "FCM token retrieved: $token")
|
||||||
|
token
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to get FCM token", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a topic
|
||||||
|
*/
|
||||||
|
suspend fun subscribeToTopic(topic: String) {
|
||||||
|
try {
|
||||||
|
FirebaseMessaging.getInstance().subscribeToTopic(topic).await()
|
||||||
|
Log.d(TAG, "Subscribed to topic: $topic")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to subscribe to topic: $topic", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from a topic
|
||||||
|
*/
|
||||||
|
suspend fun unsubscribeFromTopic(topic: String) {
|
||||||
|
try {
|
||||||
|
FirebaseMessaging.getInstance().unsubscribeFromTopic(topic).await()
|
||||||
|
Log.d(TAG, "Unsubscribed from topic: $topic")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to unsubscribe from topic: $topic", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">MyCrib</string>
|
<string name="app_name">MyCrib</string>
|
||||||
|
<string name="default_notification_channel_id">mycrib_notifications</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.mycrib.shared.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeviceRegistrationRequest(
|
||||||
|
@SerialName("registration_id")
|
||||||
|
val registrationId: String,
|
||||||
|
val platform: String // "android" or "ios"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeviceRegistrationResponse(
|
||||||
|
val id: Int,
|
||||||
|
@SerialName("registration_id")
|
||||||
|
val registrationId: String,
|
||||||
|
val platform: String,
|
||||||
|
val active: Boolean,
|
||||||
|
@SerialName("date_created")
|
||||||
|
val dateCreated: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationPreference(
|
||||||
|
val id: Int,
|
||||||
|
@SerialName("task_due_soon")
|
||||||
|
val taskDueSoon: Boolean = true,
|
||||||
|
@SerialName("task_overdue")
|
||||||
|
val taskOverdue: Boolean = true,
|
||||||
|
@SerialName("task_completed")
|
||||||
|
val taskCompleted: Boolean = true,
|
||||||
|
@SerialName("task_assigned")
|
||||||
|
val taskAssigned: Boolean = true,
|
||||||
|
@SerialName("residence_shared")
|
||||||
|
val residenceShared: Boolean = true,
|
||||||
|
@SerialName("warranty_expiring")
|
||||||
|
val warrantyExpiring: Boolean = true,
|
||||||
|
@SerialName("created_at")
|
||||||
|
val createdAt: String,
|
||||||
|
@SerialName("updated_at")
|
||||||
|
val updatedAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpdateNotificationPreferencesRequest(
|
||||||
|
@SerialName("task_due_soon")
|
||||||
|
val taskDueSoon: Boolean? = null,
|
||||||
|
@SerialName("task_overdue")
|
||||||
|
val taskOverdue: Boolean? = null,
|
||||||
|
@SerialName("task_completed")
|
||||||
|
val taskCompleted: Boolean? = null,
|
||||||
|
@SerialName("task_assigned")
|
||||||
|
val taskAssigned: Boolean? = null,
|
||||||
|
@SerialName("residence_shared")
|
||||||
|
val residenceShared: Boolean? = null,
|
||||||
|
@SerialName("warranty_expiring")
|
||||||
|
val warrantyExpiring: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Notification(
|
||||||
|
val id: Int,
|
||||||
|
@SerialName("notification_type")
|
||||||
|
val notificationType: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val data: Map<String, String> = emptyMap(),
|
||||||
|
val sent: Boolean,
|
||||||
|
@SerialName("sent_at")
|
||||||
|
val sentAt: String? = null,
|
||||||
|
val read: Boolean,
|
||||||
|
@SerialName("read_at")
|
||||||
|
val readAt: String? = null,
|
||||||
|
@SerialName("created_at")
|
||||||
|
val createdAt: String,
|
||||||
|
@SerialName("task_id")
|
||||||
|
val taskId: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UnreadCountResponse(
|
||||||
|
@SerialName("unread_count")
|
||||||
|
val unreadCount: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package com.mycrib.shared.network
|
||||||
|
|
||||||
|
import com.mycrib.shared.models.*
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
|
||||||
|
class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||||
|
private val baseUrl = ApiClient.getBaseUrl()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a device for push notifications
|
||||||
|
*/
|
||||||
|
suspend fun registerDevice(
|
||||||
|
token: String,
|
||||||
|
request: DeviceRegistrationRequest
|
||||||
|
): ApiResult<DeviceRegistrationResponse> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/notifications/devices/register/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
val errorBody = try {
|
||||||
|
response.body<Map<String, String>>()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
mapOf("error" to "Device registration failed")
|
||||||
|
}
|
||||||
|
ApiResult.Error(errorBody["error"] ?: "Device registration failed", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a device
|
||||||
|
*/
|
||||||
|
suspend fun unregisterDevice(
|
||||||
|
token: String,
|
||||||
|
registrationId: String
|
||||||
|
): ApiResult<Unit> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/notifications/devices/unregister/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(mapOf("registration_id" to registrationId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
ApiResult.Error("Device unregistration failed", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's notification preferences
|
||||||
|
*/
|
||||||
|
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
|
||||||
|
return try {
|
||||||
|
val response = client.get("$baseUrl/notifications/preferences/my_preferences/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
ApiResult.Error("Failed to get preferences", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification preferences
|
||||||
|
*/
|
||||||
|
suspend fun updateNotificationPreferences(
|
||||||
|
token: String,
|
||||||
|
request: UpdateNotificationPreferencesRequest
|
||||||
|
): ApiResult<NotificationPreference> {
|
||||||
|
return try {
|
||||||
|
val response = client.put("$baseUrl/notifications/preferences/update_preferences/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
ApiResult.Error("Failed to update preferences", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification history
|
||||||
|
*/
|
||||||
|
suspend fun getNotificationHistory(token: String): ApiResult<List<Notification>> {
|
||||||
|
return try {
|
||||||
|
val response = client.get("$baseUrl/notifications/history/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
ApiResult.Error("Failed to get notification history", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a notification as read
|
||||||
|
*/
|
||||||
|
suspend fun markNotificationAsRead(
|
||||||
|
token: String,
|
||||||
|
notificationId: Int
|
||||||
|
): ApiResult<Notification> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/notifications/history/$notificationId/mark_as_read/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
ApiResult.Error("Failed to mark notification as read", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read
|
||||||
|
*/
|
||||||
|
suspend fun markAllNotificationsAsRead(token: String): ApiResult<Map<String, Int>> {
|
||||||
|
return try {
|
||||||
|
val response = client.post("$baseUrl/notifications/history/mark_all_as_read/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
ApiResult.Error("Failed to mark all notifications as read", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread notification count
|
||||||
|
*/
|
||||||
|
suspend fun getUnreadCount(token: String): ApiResult<UnreadCountResponse> {
|
||||||
|
return try {
|
||||||
|
val response = client.get("$baseUrl/notifications/history/unread_count/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
ApiResult.Success(response.body())
|
||||||
|
} else {
|
||||||
|
ApiResult.Error("Failed to get unread count", response.status.value)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ kotlin = "2.2.20"
|
|||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.10.2"
|
||||||
kotlinx-datetime = "0.6.0"
|
kotlinx-datetime = "0.6.0"
|
||||||
ktor = "3.3.1"
|
ktor = "3.3.1"
|
||||||
|
firebase-bom = "34.0.0"
|
||||||
|
google-services = "4.4.3"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
@@ -47,6 +49,8 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
|
|||||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
||||||
|
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
|
||||||
|
firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
@@ -55,4 +59,5 @@ composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "com
|
|||||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
googleServices = { id = "com.google.gms.google-services", version.ref = "google-services" }
|
||||||
76
iosApp/iosApp/PushNotifications/AppDelegate.swift
Normal file
76
iosApp/iosApp/PushNotifications/AppDelegate.swift
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
import ComposeApp
|
||||||
|
|
||||||
|
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
// Set notification delegate
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
|
||||||
|
// Request notification permission
|
||||||
|
Task { @MainActor in
|
||||||
|
await PushNotificationManager.shared.requestNotificationPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Notifications
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||||
|
) {
|
||||||
|
Task { @MainActor in
|
||||||
|
PushNotificationManager.shared.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||||
|
) {
|
||||||
|
Task { @MainActor in
|
||||||
|
PushNotificationManager.shared.didFailToRegisterForRemoteNotifications(withError: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
|
|
||||||
|
// Called when notification is received while app is in foreground
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
let userInfo = notification.request.content.userInfo
|
||||||
|
print("📬 Notification received in foreground: \(userInfo)")
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
PushNotificationManager.shared.handleNotification(userInfo: userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification even when app is in foreground
|
||||||
|
completionHandler([.banner, .sound, .badge])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when user taps on notification
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
print("👆 User tapped notification: \(userInfo)")
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
PushNotificationManager.shared.handleNotification(userInfo: userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
206
iosApp/iosApp/PushNotifications/PushNotificationManager.swift
Normal file
206
iosApp/iosApp/PushNotifications/PushNotificationManager.swift
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
import ComposeApp
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class PushNotificationManager: NSObject, ObservableObject {
|
||||||
|
static let shared = PushNotificationManager()
|
||||||
|
|
||||||
|
@Published var deviceToken: String?
|
||||||
|
@Published var notificationPermissionGranted = false
|
||||||
|
|
||||||
|
private let notificationApi = NotificationApi()
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission Request
|
||||||
|
|
||||||
|
func requestNotificationPermission() async -> Bool {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
notificationPermissionGranted = granted
|
||||||
|
|
||||||
|
if granted {
|
||||||
|
print("✅ Notification permission granted")
|
||||||
|
// Register for remote notifications on main thread
|
||||||
|
await MainActor.run {
|
||||||
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("❌ Notification permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
print("❌ Error requesting notification permission: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token Management
|
||||||
|
|
||||||
|
func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
|
||||||
|
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
|
||||||
|
self.deviceToken = tokenString
|
||||||
|
print("📱 APNs device token: \(tokenString)")
|
||||||
|
|
||||||
|
// Register with backend
|
||||||
|
Task {
|
||||||
|
await registerDeviceWithBackend(token: tokenString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didFailToRegisterForRemoteNotifications(withError error: Error) {
|
||||||
|
print("❌ Failed to register for remote notifications: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Backend Registration
|
||||||
|
|
||||||
|
private func registerDeviceWithBackend(token: String) async {
|
||||||
|
guard let authToken = TokenStorage.shared.getToken() else {
|
||||||
|
print("⚠️ No auth token available, will register device after login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = DeviceRegistrationRequest(
|
||||||
|
registrationId: token,
|
||||||
|
platform: "ios"
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = await notificationApi.registerDevice(token: authToken, request: request)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case let success as ApiResultSuccess<DeviceRegistrationResponse>:
|
||||||
|
print("✅ Device registered successfully: \(success.data)")
|
||||||
|
case let error as ApiResultError:
|
||||||
|
print("❌ Failed to register device: \(error.message)")
|
||||||
|
default:
|
||||||
|
print("⚠️ Unexpected result type from device registration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handle Notifications
|
||||||
|
|
||||||
|
func handleNotification(userInfo: [AnyHashable: Any]) {
|
||||||
|
print("📬 Received notification: \(userInfo)")
|
||||||
|
|
||||||
|
// Extract notification data
|
||||||
|
if let notificationId = userInfo["notification_id"] as? String {
|
||||||
|
print("Notification ID: \(notificationId)")
|
||||||
|
|
||||||
|
// Mark as read when user taps notification
|
||||||
|
Task {
|
||||||
|
await markNotificationAsRead(notificationId: notificationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let type = userInfo["type"] as? String {
|
||||||
|
print("Notification type: \(type)")
|
||||||
|
handleNotificationType(type: type, userInfo: userInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
print("Task notification for task ID: \(taskId)")
|
||||||
|
// TODO: Navigate to task detail
|
||||||
|
}
|
||||||
|
|
||||||
|
case "residence_shared":
|
||||||
|
if let residenceId = userInfo["residence_id"] as? String {
|
||||||
|
print("Residence shared notification for residence ID: \(residenceId)")
|
||||||
|
// TODO: Navigate to residence detail
|
||||||
|
}
|
||||||
|
|
||||||
|
case "warranty_expiring":
|
||||||
|
if let documentId = userInfo["document_id"] as? String {
|
||||||
|
print("Warranty expiring notification for document ID: \(documentId)")
|
||||||
|
// TODO: Navigate to document detail
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
print("Unknown notification type: \(type)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markNotificationAsRead(notificationId: String) async {
|
||||||
|
guard let authToken = TokenStorage.shared.getToken(),
|
||||||
|
let notificationIdInt = Int32(notificationId) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await notificationApi.markNotificationAsRead(
|
||||||
|
token: authToken,
|
||||||
|
notificationId: notificationIdInt
|
||||||
|
)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case is ApiResultSuccess<Notification>:
|
||||||
|
print("✅ Notification marked as read")
|
||||||
|
case let error as ApiResultError:
|
||||||
|
print("❌ Failed to mark notification as read: \(error.message)")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Preferences
|
||||||
|
|
||||||
|
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
|
||||||
|
guard let authToken = TokenStorage.shared.getToken() else {
|
||||||
|
print("⚠️ No auth token available")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await notificationApi.updateNotificationPreferences(
|
||||||
|
token: authToken,
|
||||||
|
request: preferences
|
||||||
|
)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case is ApiResultSuccess<NotificationPreference>:
|
||||||
|
print("✅ Notification preferences updated")
|
||||||
|
return true
|
||||||
|
case let error as ApiResultError:
|
||||||
|
print("❌ Failed to update preferences: \(error.message)")
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNotificationPreferences() async -> NotificationPreference? {
|
||||||
|
guard let authToken = TokenStorage.shared.getToken() else {
|
||||||
|
print("⚠️ No auth token available")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await notificationApi.getNotificationPreferences(token: authToken)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case let success as ApiResultSuccess<NotificationPreference>:
|
||||||
|
return success.data
|
||||||
|
case let error as ApiResultError:
|
||||||
|
print("❌ Failed to get preferences: \(error.message)")
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Badge Management
|
||||||
|
|
||||||
|
func clearBadge() {
|
||||||
|
UIApplication.shared.applicationIconBadgeNumber = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBadge(count: Int) {
|
||||||
|
UIApplication.shared.applicationIconBadgeNumber = count
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import ComposeApp
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct iOSApp: App {
|
struct iOSApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
@State private var deepLinkResetToken: String?
|
@State private var deepLinkResetToken: String?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|||||||
Reference in New Issue
Block a user