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

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