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

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