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