Add notification preferences UI and subscription verification on launch

- Add NotificationPreferencesScreen (Android) and NotificationPreferencesView (iOS)
- Add NotificationPreferencesViewModel for shared business logic
- Wire up notification preferences from ProfileScreen on both platforms
- Add subscription verification on app launch for iOS (StoreKit) and Android (Google Play Billing)
- Update SubscriptionApi to match Go backend endpoints (/subscription/purchase/)
- Update StoreKit Configuration with correct product IDs and pricing ($2.99/month, $27.99/year)
- Update Android placeholder prices to match App Store pricing
- Fix NotificationPreference model to match Go backend schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-29 14:01:35 -06:00
parent 5a1a87fe8d
commit c748f792d0
21 changed files with 1032 additions and 38 deletions

View File

@@ -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<String?>(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)) {

View File

@@ -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)) {

View File

@@ -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 {

View File

@@ -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<ProfileRoute> { inclusive = true }
}
},
onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute)
}
)
}
composable<NotificationPreferencesRoute> {
NotificationPreferencesScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}

View File

@@ -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

View File

@@ -115,3 +115,6 @@ object VerifyResetCodeRoute
@Serializable
object ResetPasswordRoute
@Serializable
object NotificationPreferencesRoute

View File

@@ -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)"
}
}
}

View File

@@ -67,7 +67,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
*/
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
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<NotificationPreference> {
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)

View File

@@ -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<VerificationResponse> {
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<VerificationResponse> {
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<VerificationResponse> {
return try {
val body = mutableMapOf<String, String>("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")
}
}
}

View File

@@ -243,7 +243,20 @@ fun MainScreen(
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
onLogout = onLogout
onLogout = onLogout,
onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute)
}
)
}
}
composable<NotificationPreferencesRoute> {
Box(modifier = Modifier.fillMaxSize()) {
NotificationPreferencesScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
}

View File

@@ -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
)
)
}
}

View File

@@ -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))

View File

@@ -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") }

View File

@@ -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<NotificationPreference>>(ApiResult.Idle)
val preferencesState: StateFlow<ApiResult<NotificationPreference>> = _preferencesState
private val _updateState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
val updateState: StateFlow<ApiResult<NotificationPreference>> = _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
}
}

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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<Void, Never>?
private var updateTask: Task<Void, Never>?
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<NotificationPreference>:
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<NotificationPreference> || 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<NotificationPreference>:
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<NotificationPreference> || state is ApiResultError {
break
}
}
}
}
deinit {
preferencesTask?.cancel()
updateTask?.cancel()
}
}
#Preview {
NotificationPreferencesView()
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)")
}

View File

@@ -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<ComposeApp.SubscriptionStatus>,
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 {