diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt index f8a67fb..7329aab 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt @@ -30,10 +30,12 @@ import com.example.casera.storage.ThemeStorage import com.example.casera.storage.ThemeStorageManager import com.example.casera.ui.theme.ThemeManager import com.example.casera.fcm.FCMManager +import com.example.casera.platform.BillingManager import kotlinx.coroutines.launch class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { private var deepLinkResetToken by mutableStateOf(null) + private lateinit var billingManager: BillingManager override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() @@ -49,12 +51,18 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext)) ThemeManager.initialize() + // Initialize BillingManager for subscription management + billingManager = BillingManager.getInstance(applicationContext) + // Handle deep link from intent handleDeepLink(intent) // Request notification permission and setup FCM setupFCM() + // Verify subscriptions if user is authenticated + verifySubscriptionsOnLaunch() + setContent { App( deepLinkResetToken = deepLinkResetToken, @@ -65,6 +73,36 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { } } + /** + * Verify subscriptions with Google Play and sync with backend on app launch + */ + private fun verifySubscriptionsOnLaunch() { + val authToken = TokenStorage.getToken() + if (authToken == null) { + Log.d("MainActivity", "No auth token, skipping subscription verification") + return + } + + Log.d("MainActivity", "🔄 Verifying subscriptions on launch...") + + billingManager.startConnection( + onSuccess = { + Log.d("MainActivity", "✅ Billing connected, restoring purchases...") + lifecycleScope.launch { + val restored = billingManager.restorePurchases() + if (restored) { + Log.d("MainActivity", "✅ Subscriptions verified and synced with backend") + } else { + Log.d("MainActivity", "📦 No active subscriptions found") + } + } + }, + onError = { error -> + Log.e("MainActivity", "❌ Failed to connect to billing: $error") + } + ) + } + private fun setupFCM() { // Request notification permission if needed if (!FCMManager.isNotificationPermissionGranted(this)) { @@ -86,9 +124,15 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { val authToken = TokenStorage.getToken() if (authToken != null) { val notificationApi = com.example.casera.network.NotificationApi() + val deviceId = android.provider.Settings.Secure.getString( + contentResolver, + android.provider.Settings.Secure.ANDROID_ID + ) val request = com.example.casera.models.DeviceRegistrationRequest( + deviceId = deviceId, registrationId = fcmToken, - platform = "android" + platform = "android", + name = android.os.Build.MODEL ) when (val result = notificationApi.registerDevice(authToken, request)) { diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt index 6190fb9..e6fdd26 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MyFirebaseMessagingService.kt @@ -33,9 +33,15 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { val authToken = com.example.casera.storage.TokenStorage.getToken() if (authToken != null) { val notificationApi = com.example.casera.network.NotificationApi() + val deviceId = android.provider.Settings.Secure.getString( + applicationContext.contentResolver, + android.provider.Settings.Secure.ANDROID_ID + ) val request = com.example.casera.models.DeviceRegistrationRequest( + deviceId = deviceId, registrationId = token, - platform = "android" + platform = "android", + name = android.os.Build.MODEL ) when (val result = notificationApi.registerDevice(authToken, request)) { diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/BillingManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/BillingManager.kt index c2d56e5..5b84a1b 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/platform/BillingManager.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/BillingManager.kt @@ -350,6 +350,8 @@ class BillingManager private constructor(private val context: Context) { true } else { Log.d(TAG, "No active purchases to restore") + // Still fetch subscription status from backend to get free tier limits + APILayer.getSubscriptionStatus(forceRefresh = true) false } } else { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index 9a1404e..51fa82a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -39,6 +39,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.example.casera.ui.screens.MainScreen +import com.example.casera.ui.screens.NotificationPreferencesScreen import com.example.casera.ui.screens.ProfileScreen import com.example.casera.ui.theme.MyCribTheme import com.example.casera.ui.theme.ThemeManager @@ -544,6 +545,17 @@ fun App( navController.navigate(LoginRoute) { popUpTo { inclusive = true } } + }, + onNavigateToNotificationPreferences = { + navController.navigate(NotificationPreferencesRoute) + } + ) + } + + composable { + NotificationPreferencesScreen( + onNavigateBack = { + navController.popBackStack() } ) } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt index 6d4b08e..bbb6964 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt @@ -5,14 +5,20 @@ import kotlinx.serialization.Serializable @Serializable data class DeviceRegistrationRequest( + @SerialName("device_id") + val deviceId: String, @SerialName("registration_id") val registrationId: String, - val platform: String // "android" or "ios" + val platform: String, // "android" or "ios" + val name: String? = null ) @Serializable data class DeviceRegistrationResponse( val id: Int, + val name: String? = null, + @SerialName("device_id") + val deviceId: String, @SerialName("registration_id") val registrationId: String, val platform: String, @@ -23,7 +29,6 @@ data class DeviceRegistrationResponse( @Serializable data class NotificationPreference( - val id: Int, @SerialName("task_due_soon") val taskDueSoon: Boolean = true, @SerialName("task_overdue") @@ -35,11 +40,7 @@ data class NotificationPreference( @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 + val warrantyExpiring: Boolean = true ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt index ef61513..7855ef1 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt @@ -115,3 +115,6 @@ object VerifyResetCodeRoute @Serializable object ResetPasswordRoute + +@Serializable +object NotificationPreferencesRoute diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt index 4ff1da0..45cffa3 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt @@ -22,7 +22,7 @@ object ApiConfig { fun getBaseUrl(): String { return when (CURRENT_ENV) { Environment.LOCAL -> "http://${getLocalhostAddress()}:8000/api" - Environment.DEV -> "https://mycrib.treytartt.com/api" + Environment.DEV -> "https://casera.treytartt.com/api" } } @@ -32,7 +32,7 @@ object ApiConfig { fun getMediaBaseUrl(): String { return when (CURRENT_ENV) { Environment.LOCAL -> "http://${getLocalhostAddress()}:8000" - Environment.DEV -> "https://mycrib.treytartt.com" + Environment.DEV -> "https://casera.treytartt.com" } } @@ -42,7 +42,7 @@ object ApiConfig { fun getEnvironmentName(): String { return when (CURRENT_ENV) { Environment.LOCAL -> "Local (${getLocalhostAddress()}:8000)" - Environment.DEV -> "Dev Server (mycrib.treytartt.com)" + Environment.DEV -> "Dev Server (casera.treytartt.com)" } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/NotificationApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/NotificationApi.kt index b505659..0ec1ded 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/NotificationApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/NotificationApi.kt @@ -67,7 +67,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { */ suspend fun getNotificationPreferences(token: String): ApiResult { return try { - val response = client.get("$baseUrl/notifications/preferences/my_preferences/") { + val response = client.get("$baseUrl/notifications/preferences/") { header("Authorization", "Token $token") } @@ -89,7 +89,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) { request: UpdateNotificationPreferencesRequest ): ApiResult { return try { - val response = client.put("$baseUrl/notifications/preferences/update_preferences/") { + val response = client.put("$baseUrl/notifications/preferences/") { header("Authorization", "Token $token") contentType(ContentType.Application.Json) setBody(request) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt index cf9c91d..aabf720 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt @@ -71,16 +71,21 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { } } + /** + * Verify/process iOS purchase with backend + * Used for both new purchases and restore + */ suspend fun verifyIOSReceipt( token: String, receiptData: String, transactionId: String ): ApiResult { return try { - val response = client.post("$baseUrl/subscription/verify-ios/") { + val response = client.post("$baseUrl/subscription/purchase/") { header("Authorization", "Token $token") contentType(ContentType.Application.Json) setBody(mapOf( + "platform" to "ios", "receipt_data" to receiptData, "transaction_id" to transactionId )) @@ -96,16 +101,21 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { } } + /** + * Verify/process Android purchase with backend + * Used for both new purchases and restore + */ suspend fun verifyAndroidPurchase( token: String, purchaseToken: String, productId: String ): ApiResult { return try { - val response = client.post("$baseUrl/subscription/verify-android/") { + val response = client.post("$baseUrl/subscription/purchase/") { header("Authorization", "Token $token") contentType(ContentType.Application.Json) setBody(mapOf( + "platform" to "android", "purchase_token" to purchaseToken, "product_id" to productId )) @@ -120,4 +130,39 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + /** + * Restore subscription from iOS/Android receipt + */ + suspend fun restoreSubscription( + token: String, + platform: String, + receiptData: String? = null, + purchaseToken: String? = null, + productId: String? = null + ): ApiResult { + return try { + val body = mutableMapOf("platform" to platform) + if (platform == "ios" && receiptData != null) { + body["receipt_data"] = receiptData + } else if (platform == "android" && purchaseToken != null) { + body["purchase_token"] = purchaseToken + productId?.let { body["product_id"] = it } + } + + val response = client.post("$baseUrl/subscription/restore/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(body) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to restore subscription", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt index 9ae67f6..036cd46 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt @@ -243,7 +243,20 @@ fun MainScreen( selectedTab = 0 navController.navigate(MainTabResidencesRoute) }, - onLogout = onLogout + onLogout = onLogout, + onNavigateToNotificationPreferences = { + navController.navigate(NotificationPreferencesRoute) + } + ) + } + } + + composable { + Box(modifier = Modifier.fillMaxSize()) { + NotificationPreferencesScreen( + onNavigateBack = { + navController.popBackStack() + } ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt new file mode 100644 index 0000000..4b8cec7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt @@ -0,0 +1,349 @@ +package com.example.casera.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.casera.network.ApiResult +import com.example.casera.ui.theme.AppRadius +import com.example.casera.ui.theme.AppSpacing +import com.example.casera.viewmodel.NotificationPreferencesViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationPreferencesScreen( + onNavigateBack: () -> Unit, + viewModel: NotificationPreferencesViewModel = viewModel { NotificationPreferencesViewModel() } +) { + val preferencesState by viewModel.preferencesState.collectAsState() + val updateState by viewModel.updateState.collectAsState() + + var taskDueSoon by remember { mutableStateOf(true) } + var taskOverdue by remember { mutableStateOf(true) } + var taskCompleted by remember { mutableStateOf(true) } + var taskAssigned by remember { mutableStateOf(true) } + var residenceShared by remember { mutableStateOf(true) } + var warrantyExpiring by remember { mutableStateOf(true) } + + // Load preferences on first render + LaunchedEffect(Unit) { + viewModel.loadPreferences() + } + + // Update local state when preferences load + LaunchedEffect(preferencesState) { + if (preferencesState is ApiResult.Success) { + val prefs = (preferencesState as ApiResult.Success).data + taskDueSoon = prefs.taskDueSoon + taskOverdue = prefs.taskOverdue + taskCompleted = prefs.taskCompleted + taskAssigned = prefs.taskAssigned + residenceShared = prefs.residenceShared + warrantyExpiring = prefs.warrantyExpiring + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Notifications", fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md), + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + // Header + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.lg), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + modifier = Modifier.size(60.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + "Notification Preferences", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Text( + "Choose which notifications you'd like to receive", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + when (preferencesState) { + is ApiResult.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpacing.xl), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is ApiResult.Error -> { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(AppRadius.md) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpacing.lg), + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + (preferencesState as ApiResult.Error).message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + Button( + onClick = { viewModel.loadPreferences() }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Retry") + } + } + } + } + + is ApiResult.Success, is ApiResult.Idle -> { + // Task Notifications Section + Text( + "Task Notifications", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = AppSpacing.md) + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.md), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column { + NotificationToggle( + title = "Task Due Soon", + description = "Reminders for upcoming tasks", + icon = Icons.Default.Schedule, + iconTint = MaterialTheme.colorScheme.tertiary, + checked = taskDueSoon, + onCheckedChange = { + taskDueSoon = it + viewModel.updatePreference(taskDueSoon = it) + } + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = AppSpacing.lg), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + NotificationToggle( + title = "Task Overdue", + description = "Alerts for overdue tasks", + icon = Icons.Default.Warning, + iconTint = MaterialTheme.colorScheme.error, + checked = taskOverdue, + onCheckedChange = { + taskOverdue = it + viewModel.updatePreference(taskOverdue = it) + } + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = AppSpacing.lg), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + NotificationToggle( + title = "Task Completed", + description = "When someone completes a task", + icon = Icons.Default.CheckCircle, + iconTint = MaterialTheme.colorScheme.primary, + checked = taskCompleted, + onCheckedChange = { + taskCompleted = it + viewModel.updatePreference(taskCompleted = it) + } + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = AppSpacing.lg), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + NotificationToggle( + title = "Task Assigned", + description = "When a task is assigned to you", + icon = Icons.Default.PersonAdd, + iconTint = MaterialTheme.colorScheme.secondary, + checked = taskAssigned, + onCheckedChange = { + taskAssigned = it + viewModel.updatePreference(taskAssigned = it) + } + ) + } + } + + // Other Notifications Section + Text( + "Other Notifications", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = AppSpacing.md) + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.md), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column { + NotificationToggle( + title = "Property Shared", + description = "When someone shares a property with you", + icon = Icons.Default.Home, + iconTint = MaterialTheme.colorScheme.primary, + checked = residenceShared, + onCheckedChange = { + residenceShared = it + viewModel.updatePreference(residenceShared = it) + } + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = AppSpacing.lg), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + NotificationToggle( + title = "Warranty Expiring", + description = "Reminders for expiring warranties", + icon = Icons.Default.Description, + iconTint = MaterialTheme.colorScheme.tertiary, + checked = warrantyExpiring, + onCheckedChange = { + warrantyExpiring = it + viewModel.updatePreference(warrantyExpiring = it) + } + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl)) + } + } + } + } +} + +@Composable +private fun NotificationToggle( + title: String, + description: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + iconTint: androidx.compose.ui.graphics.Color, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpacing.lg), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary + ) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt index bded323..ec40007 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue fun ProfileScreen( onNavigateBack: () -> Unit, onLogout: () -> Unit, + onNavigateToNotificationPreferences: () -> Unit = {}, viewModel: AuthViewModel = viewModel { AuthViewModel() } ) { var firstName by remember { mutableStateOf("") } @@ -208,6 +209,45 @@ fun ProfileScreen( } } + // Notification Preferences Section + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onNavigateToNotificationPreferences() }, + shape = RoundedCornerShape(AppRadius.md), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpacing.lg), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + verticalArrangement = Arrangement.spacedBy(AppSpacing.xs) + ) { + Text( + text = "Notifications", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Manage notification preferences", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = "Notification preferences", + tint = MaterialTheme.colorScheme.primary + ) + } + } + // Subscription Section - Only show if limitations are enabled if (currentSubscription?.limitationsEnabled == true) { Divider(modifier = Modifier.padding(vertical = AppSpacing.sm)) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreen.kt index ee27a03..bafdbe8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/subscription/UpgradeFeatureScreen.kt @@ -253,8 +253,8 @@ private fun SubscriptionProductsSection( // Monthly Option SubscriptionProductCard( productId = "com.example.casera.pro.monthly", - name = "MyCrib Pro Monthly", - price = "$4.99/month", + name = "Casera Pro Monthly", + price = "$2.99/month", description = "Billed monthly", savingsBadge = null, isSelected = false, @@ -265,10 +265,10 @@ private fun SubscriptionProductsSection( // Annual Option SubscriptionProductCard( productId = "com.example.casera.pro.annual", - name = "MyCrib Pro Annual", - price = "$39.99/year", + name = "Casera Pro Annual", + price = "$27.99/year", description = "Billed annually", - savingsBadge = "Save 33%", + savingsBadge = "Save 22%", isSelected = false, isProcessing = isProcessing, onSelect = { onProductSelected("com.example.casera.pro.annual") } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt new file mode 100644 index 0000000..4747830 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt @@ -0,0 +1,67 @@ +package com.example.casera.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.casera.models.NotificationPreference +import com.example.casera.models.UpdateNotificationPreferencesRequest +import com.example.casera.network.ApiResult +import com.example.casera.network.APILayer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class NotificationPreferencesViewModel : ViewModel() { + + private val _preferencesState = MutableStateFlow>(ApiResult.Idle) + val preferencesState: StateFlow> = _preferencesState + + private val _updateState = MutableStateFlow>(ApiResult.Idle) + val updateState: StateFlow> = _updateState + + fun loadPreferences() { + viewModelScope.launch { + _preferencesState.value = ApiResult.Loading + val result = APILayer.getNotificationPreferences() + _preferencesState.value = when (result) { + is ApiResult.Success -> ApiResult.Success(result.data) + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } + } + + fun updatePreference( + taskDueSoon: Boolean? = null, + taskOverdue: Boolean? = null, + taskCompleted: Boolean? = null, + taskAssigned: Boolean? = null, + residenceShared: Boolean? = null, + warrantyExpiring: Boolean? = null + ) { + viewModelScope.launch { + _updateState.value = ApiResult.Loading + val request = UpdateNotificationPreferencesRequest( + taskDueSoon = taskDueSoon, + taskOverdue = taskOverdue, + taskCompleted = taskCompleted, + taskAssigned = taskAssigned, + residenceShared = residenceShared, + warrantyExpiring = warrantyExpiring + ) + val result = APILayer.updateNotificationPreferences(request) + _updateState.value = when (result) { + is ApiResult.Success -> { + // Update the preferences state with the new values + _preferencesState.value = ApiResult.Success(result.data) + ApiResult.Success(result.data) + } + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } + } + + fun resetUpdateState() { + _updateState.value = ApiResult.Idle + } +} diff --git a/docs/ANDROID_SUBSCRIPTION_PLAN.md b/docs/ANDROID_SUBSCRIPTION_PLAN.md index 64d44c7..30a9eb2 100644 --- a/docs/ANDROID_SUBSCRIPTION_PLAN.md +++ b/docs/ANDROID_SUBSCRIPTION_PLAN.md @@ -58,8 +58,8 @@ Replace stub implementation with full Google Play Billing: class BillingManager private constructor(private val context: Context) { // Product IDs (match Google Play Console) private val productIDs = listOf( - "com.example.mycrib.pro.monthly", - "com.example.mycrib.pro.annual" + "com.example.casera.pro.monthly", + "com.example.casera.pro.annual" ) // BillingClient instance @@ -270,7 +270,7 @@ These files show the iOS implementation to mirror: ## Notes -- Product IDs must match Google Play Console: `com.example.mycrib.pro.monthly`, `com.example.mycrib.pro.annual` +- Product IDs must match Google Play Console: `com.example.casera.pro.monthly`, `com.example.casera.pro.annual` - Backend endpoint `POST /subscription/verify-android/` already exists in SubscriptionApi - Testing requires Google Play Console setup with test products - Use Google's test cards for sandbox testing diff --git a/iosApp/iosApp/Configuration.storekit b/iosApp/iosApp/Configuration.storekit index 4f98ff2..f988805 100644 --- a/iosApp/iosApp/Configuration.storekit +++ b/iosApp/iosApp/Configuration.storekit @@ -76,7 +76,7 @@ "codeOffers" : [ ], - "displayPrice" : "4.99", + "displayPrice" : "2.99", "familyShareable" : false, "groupNumber" : 1, "internalID" : "6738711291", @@ -89,13 +89,13 @@ "localizations" : [ { "description" : "Unlock unlimited properties, tasks, contractors, and documents", - "displayName" : "MyCrib Pro Monthly", + "displayName" : "Casera Pro Monthly", "locale" : "en_US" } ], - "productID" : "com.example.mycrib.pro.monthly", + "productID" : "com.example.casera.pro.monthly", "recurringSubscriptionPeriod" : "P1M", - "referenceName" : "MyCrib Pro Monthly", + "referenceName" : "Casera Pro Monthly", "subscriptionGroupID" : "21517970", "type" : "RecurringSubscription" }, @@ -106,7 +106,7 @@ "codeOffers" : [ ], - "displayPrice" : "49.99", + "displayPrice" : "27.99", "familyShareable" : false, "groupNumber" : 2, "internalID" : "6738711458", @@ -118,14 +118,14 @@ }, "localizations" : [ { - "description" : "Unlock unlimited properties, tasks, contractors, and documents - Save 17% with annual billing", - "displayName" : "MyCrib Pro Annual", + "description" : "Unlock unlimited properties, tasks, contractors, and documents - Save 22% with annual billing", + "displayName" : "Casera Pro Annual", "locale" : "en_US" } ], - "productID" : "com.example.mycrib.pro.annual", + "productID" : "com.example.casera.pro.annual", "recurringSubscriptionPeriod" : "P1Y", - "referenceName" : "MyCrib Pro Annual", + "referenceName" : "Casera Pro Annual", "subscriptionGroupID" : "21517970", "type" : "RecurringSubscription" } diff --git a/iosApp/iosApp/Profile/NotificationPreferencesView.swift b/iosApp/iosApp/Profile/NotificationPreferencesView.swift new file mode 100644 index 0000000..e0eb20e --- /dev/null +++ b/iosApp/iosApp/Profile/NotificationPreferencesView.swift @@ -0,0 +1,325 @@ +import SwiftUI +import ComposeApp + +struct NotificationPreferencesView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = NotificationPreferencesViewModelWrapper() + + var body: some View { + NavigationStack { + Form { + // Header Section + Section { + VStack(spacing: 16) { + Image(systemName: "bell.badge.fill") + .font(.system(size: 60)) + .foregroundStyle(Color.appPrimary.gradient) + + Text("Notification Preferences") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(Color.appTextPrimary) + + Text("Choose which notifications you'd like to receive") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical) + } + .listRowBackground(Color.clear) + + if viewModel.isLoading { + Section { + HStack { + Spacer() + ProgressView() + .tint(Color.appPrimary) + Spacer() + } + .padding(.vertical, 20) + } + .listRowBackground(Color.appBackgroundSecondary) + } else if let errorMessage = viewModel.errorMessage { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(Color.appError) + Text(errorMessage) + .foregroundColor(Color.appError) + .font(.subheadline) + } + + Button("Retry") { + viewModel.loadPreferences() + } + .foregroundColor(Color.appPrimary) + } + .listRowBackground(Color.appBackgroundSecondary) + } else { + // Task Notifications + Section { + Toggle(isOn: $viewModel.taskDueSoon) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Task Due Soon") + .foregroundColor(Color.appTextPrimary) + Text("Reminders for upcoming tasks") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } icon: { + Image(systemName: "clock.fill") + .foregroundColor(Color.appAccent) + } + } + .tint(Color.appPrimary) + .onChange(of: viewModel.taskDueSoon) { _, newValue in + viewModel.updatePreference(taskDueSoon: newValue) + } + + Toggle(isOn: $viewModel.taskOverdue) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Task Overdue") + .foregroundColor(Color.appTextPrimary) + Text("Alerts for overdue tasks") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } icon: { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color.appError) + } + } + .tint(Color.appPrimary) + .onChange(of: viewModel.taskOverdue) { _, newValue in + viewModel.updatePreference(taskOverdue: newValue) + } + + Toggle(isOn: $viewModel.taskCompleted) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Task Completed") + .foregroundColor(Color.appTextPrimary) + Text("When someone completes a task") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } icon: { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color.appPrimary) + } + } + .tint(Color.appPrimary) + .onChange(of: viewModel.taskCompleted) { _, newValue in + viewModel.updatePreference(taskCompleted: newValue) + } + + Toggle(isOn: $viewModel.taskAssigned) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Task Assigned") + .foregroundColor(Color.appTextPrimary) + Text("When a task is assigned to you") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } icon: { + Image(systemName: "person.badge.plus.fill") + .foregroundColor(Color.appSecondary) + } + } + .tint(Color.appPrimary) + .onChange(of: viewModel.taskAssigned) { _, newValue in + viewModel.updatePreference(taskAssigned: newValue) + } + } header: { + Text("Task Notifications") + } + .listRowBackground(Color.appBackgroundSecondary) + + // Other Notifications + Section { + Toggle(isOn: $viewModel.residenceShared) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Property Shared") + .foregroundColor(Color.appTextPrimary) + Text("When someone shares a property with you") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } icon: { + Image(systemName: "house.fill") + .foregroundColor(Color.appPrimary) + } + } + .tint(Color.appPrimary) + .onChange(of: viewModel.residenceShared) { _, newValue in + viewModel.updatePreference(residenceShared: newValue) + } + + Toggle(isOn: $viewModel.warrantyExpiring) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Warranty Expiring") + .foregroundColor(Color.appTextPrimary) + Text("Reminders for expiring warranties") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } icon: { + Image(systemName: "doc.badge.clock.fill") + .foregroundColor(Color.appAccent) + } + } + .tint(Color.appPrimary) + .onChange(of: viewModel.warrantyExpiring) { _, newValue in + viewModel.updatePreference(warrantyExpiring: newValue) + } + } header: { + Text("Other Notifications") + } + .listRowBackground(Color.appBackgroundSecondary) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.appBackgroundPrimary) + .navigationTitle("Notifications") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + .foregroundColor(Color.appPrimary) + } + } + } + .onAppear { + viewModel.loadPreferences() + } + } +} + +// MARK: - ViewModel Wrapper + +@MainActor +class NotificationPreferencesViewModelWrapper: ObservableObject { + @Published var taskDueSoon: Bool = true + @Published var taskOverdue: Bool = true + @Published var taskCompleted: Bool = true + @Published var taskAssigned: Bool = true + @Published var residenceShared: Bool = true + @Published var warrantyExpiring: Bool = true + + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var isSaving: Bool = false + + private let sharedViewModel = ComposeApp.NotificationPreferencesViewModel() + private var preferencesTask: Task? + private var updateTask: Task? + + func loadPreferences() { + preferencesTask?.cancel() + isLoading = true + errorMessage = nil + + sharedViewModel.loadPreferences() + + preferencesTask = Task { + for await state in sharedViewModel.preferencesState { + if Task.isCancelled { break } + + await MainActor.run { + switch state { + case let success as ApiResultSuccess: + if let prefs = success.data { + self.taskDueSoon = prefs.taskDueSoon + self.taskOverdue = prefs.taskOverdue + self.taskCompleted = prefs.taskCompleted + self.taskAssigned = prefs.taskAssigned + self.residenceShared = prefs.residenceShared + self.warrantyExpiring = prefs.warrantyExpiring + } + self.isLoading = false + self.errorMessage = nil + case let error as ApiResultError: + self.errorMessage = error.message + self.isLoading = false + case is ApiResultLoading: + self.isLoading = true + default: + break + } + } + + // Break after success or error + if state is ApiResultSuccess || state is ApiResultError { + break + } + } + } + } + + func updatePreference( + taskDueSoon: Bool? = nil, + taskOverdue: Bool? = nil, + taskCompleted: Bool? = nil, + taskAssigned: Bool? = nil, + residenceShared: Bool? = nil, + warrantyExpiring: Bool? = nil + ) { + updateTask?.cancel() + isSaving = true + + sharedViewModel.updatePreference( + taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) }, + taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) }, + taskCompleted: taskCompleted.map { KotlinBoolean(bool: $0) }, + taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) }, + residenceShared: residenceShared.map { KotlinBoolean(bool: $0) }, + warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) } + ) + + updateTask = Task { + for await state in sharedViewModel.updateState { + if Task.isCancelled { break } + + await MainActor.run { + switch state { + case is ApiResultSuccess: + self.isSaving = false + self.sharedViewModel.resetUpdateState() + case let error as ApiResultError: + self.errorMessage = error.message + self.isSaving = false + self.sharedViewModel.resetUpdateState() + case is ApiResultLoading: + self.isSaving = true + default: + break + } + } + + // Break after success or error + if state is ApiResultSuccess || state is ApiResultError { + break + } + } + } + } + + deinit { + preferencesTask?.cancel() + updateTask?.cancel() + } +} + +#Preview { + NotificationPreferencesView() +} diff --git a/iosApp/iosApp/Profile/ProfileTabView.swift b/iosApp/iosApp/Profile/ProfileTabView.swift index eeb7bc6..e93446d 100644 --- a/iosApp/iosApp/Profile/ProfileTabView.swift +++ b/iosApp/iosApp/Profile/ProfileTabView.swift @@ -10,6 +10,7 @@ struct ProfileTabView: View { @State private var showingThemeSelection = false @State private var showUpgradePrompt = false @State private var showRestoreSuccess = false + @State private var showingNotificationPreferences = false var body: some View { List { @@ -42,8 +43,17 @@ struct ProfileTabView: View { .foregroundColor(Color.appTextPrimary) } - NavigationLink(destination: Text("Notifications")) { - Label("Notifications", systemImage: "bell") + Button(action: { + showingNotificationPreferences = true + }) { + HStack { + Label("Notifications", systemImage: "bell") + .foregroundColor(Color.appTextPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } } NavigationLink(destination: Text("Privacy")) { @@ -163,6 +173,9 @@ struct ProfileTabView: View { .sheet(isPresented: $showingThemeSelection) { ThemeSelectionView() } + .sheet(isPresented: $showingNotificationPreferences) { + NotificationPreferencesView() + } .alert("Log Out", isPresented: $showingLogoutAlert) { Button("Cancel", role: .cancel) { } Button("Log Out", role: .destructive) { diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift index 89facd2..f07bc78 100644 --- a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -63,10 +63,20 @@ class PushNotificationManager: NSObject, ObservableObject { print("⚠️ No auth token available, will register device after login") return } - + + // Get unique device identifier + let deviceId = await MainActor.run { + UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + } + let deviceName = await MainActor.run { + UIDevice.current.name + } + let request = DeviceRegistrationRequest( + deviceId: deviceId, registrationId: token, - platform: "ios" + platform: "ios", + name: deviceName ) do { diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index 33c0a5a..5a380f5 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -40,6 +40,9 @@ class AuthenticationManager: ObservableObject { if self.isVerified { _ = try await APILayer.shared.initializeLookups() print("✅ Lookups initialized on app launch for verified user") + + // Verify subscription entitlements with backend + await StoreKitManager.shared.verifyEntitlementsOnLaunch() } } else if result is ApiResultError { // Token is invalid, clear it @@ -72,6 +75,9 @@ class AuthenticationManager: ObservableObject { do { _ = try await APILayer.shared.initializeLookups() print("✅ Lookups initialized after email verification") + + // Verify subscription entitlements with backend + await StoreKitManager.shared.verifyEntitlementsOnLaunch() } catch { print("❌ Failed to initialize lookups after verification: \(error)") } diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index d1765b0..60a95c4 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -135,6 +135,64 @@ class StoreKitManager: ObservableObject { print("🔄 StoreKit: Subscription status refreshed") } + /// Verify current entitlements with backend on app launch + /// This ensures the backend has up-to-date subscription info + func verifyEntitlementsOnLaunch() async { + print("🔄 StoreKit: Verifying entitlements on launch...") + + // Get current entitlements from StoreKit + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result else { + continue + } + + // Skip if revoked + if transaction.revocationDate != nil { + continue + } + + // Only process subscription products + if transaction.productType == .autoRenewable { + print("📦 StoreKit: Found active subscription: \(transaction.productID)") + + // Verify this transaction with backend + await verifyTransactionWithBackend(transaction) + + // Update local purchased products + await MainActor.run { + purchasedProductIDs.insert(transaction.productID) + } + } + } + + // After verifying all entitlements, refresh subscription status from backend + await refreshSubscriptionFromBackend() + + print("✅ StoreKit: Launch verification complete") + } + + /// Fetch latest subscription status from backend and update cache + private func refreshSubscriptionFromBackend() async { + guard let token = TokenStorage.shared.getToken() else { + print("⚠️ StoreKit: No auth token, skipping backend status refresh") + return + } + + do { + let statusResult = try await subscriptionApi.getSubscriptionStatus(token: token) + + if let statusSuccess = statusResult as? ApiResultSuccess, + let subscription = statusSuccess.data { + await MainActor.run { + SubscriptionCacheWrapper.shared.updateSubscription(subscription) + print("✅ StoreKit: Backend subscription status updated - Tier: \(subscription.limits)") + } + } + } catch { + print("❌ StoreKit: Failed to refresh subscription from backend: \(error)") + } + } + /// Update purchased product IDs @MainActor private func updatePurchasedProducts() async {