diff --git a/build.gradle.kts b/build.gradle.kts index 98ddb8c..c6a2745 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,5 @@ plugins { alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.googleServices) apply false } \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 74df536..95eca0c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,6 +1,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.plugin.KotlinDependencyHandler plugins { alias(libs.plugins.kotlinMultiplatform) @@ -9,6 +10,7 @@ plugins { alias(libs.plugins.composeCompiler) alias(libs.plugins.composeHotReload) alias(libs.plugins.kotlinxSerialization) + alias(libs.plugins.googleServices) } kotlin { @@ -120,6 +122,10 @@ android { dependencies { 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 { diff --git a/composeApp/google-services.json b/composeApp/google-services.json new file mode 100644 index 0000000..06dfae7 --- /dev/null +++ b/composeApp/google-services.json @@ -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" +} diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 8f16915..2bcd961 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -3,6 +3,7 @@ + @@ -50,6 +51,20 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + + + + + + + + + \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt index 02a7978..d806014 100644 --- a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt @@ -3,6 +3,7 @@ package com.example.mycrib import android.content.Intent import android.net.Uri import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -11,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.lifecycleScope import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader @@ -24,6 +26,8 @@ import com.mycrib.storage.TokenManager import com.mycrib.storage.TokenStorage import com.mycrib.storage.TaskCacheManager import com.mycrib.storage.TaskCacheStorage +import com.example.mycrib.fcm.FCMManager +import kotlinx.coroutines.launch class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { private var deepLinkResetToken by mutableStateOf(null) @@ -41,6 +45,9 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { // Handle deep link from intent handleDeepLink(intent) + // Request notification permission and setup FCM + setupFCM() + setContent { App( 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, + 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) { super.onNewIntent(intent) handleDeepLink(intent) diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/MyFirebaseMessagingService.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/MyFirebaseMessagingService.kt new file mode 100644 index 0000000..dfbd363 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/MyFirebaseMessagingService.kt @@ -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) { + 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) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/fcm/FCMManager.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/fcm/FCMManager.kt new file mode 100644 index 0000000..7235631 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/fcm/FCMManager.kt @@ -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) + } + } +} diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml index 4b9810c..fa8c4d1 100644 --- a/composeApp/src/androidMain/res/values/strings.xml +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -1,3 +1,4 @@ MyCrib + mycrib_notifications \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Notification.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Notification.kt new file mode 100644 index 0000000..a6be874 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Notification.kt @@ -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 = 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 +) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/NotificationApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/NotificationApi.kt new file mode 100644 index 0000000..942cd2b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/NotificationApi.kt @@ -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 { + 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>() + } 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 { + 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 { + 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 { + 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> { + 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 { + 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> { + 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 { + 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") + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b812f58..8ceb3c3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,8 @@ kotlin = "2.2.20" kotlinx-coroutines = "1.10.2" kotlinx-datetime = "0.6.0" ktor = "3.3.1" +firebase-bom = "34.0.0" +google-services = "4.4.3" [libraries] 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" } 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" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" } [plugins] 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" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file +kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +googleServices = { id = "com.google.gms.google-services", version.ref = "google-services" } \ No newline at end of file diff --git a/iosApp/iosApp/PushNotifications/AppDelegate.swift b/iosApp/iosApp/PushNotifications/AppDelegate.swift new file mode 100644 index 0000000..45de65f --- /dev/null +++ b/iosApp/iosApp/PushNotifications/AppDelegate.swift @@ -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() + } +} diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift new file mode 100644 index 0000000..6668758 --- /dev/null +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -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: + 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: + 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: + 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: + 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 + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 4664838..07486b8 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -3,6 +3,7 @@ import ComposeApp @main struct iOSApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @State private var deepLinkResetToken: String? init() {