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:
@@ -30,10 +30,12 @@ import com.example.casera.storage.ThemeStorage
|
|||||||
import com.example.casera.storage.ThemeStorageManager
|
import com.example.casera.storage.ThemeStorageManager
|
||||||
import com.example.casera.ui.theme.ThemeManager
|
import com.example.casera.ui.theme.ThemeManager
|
||||||
import com.example.casera.fcm.FCMManager
|
import com.example.casera.fcm.FCMManager
|
||||||
|
import com.example.casera.platform.BillingManager
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||||
|
private lateinit var billingManager: BillingManager
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -49,12 +51,18 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext))
|
ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext))
|
||||||
ThemeManager.initialize()
|
ThemeManager.initialize()
|
||||||
|
|
||||||
|
// Initialize BillingManager for subscription management
|
||||||
|
billingManager = BillingManager.getInstance(applicationContext)
|
||||||
|
|
||||||
// Handle deep link from intent
|
// Handle deep link from intent
|
||||||
handleDeepLink(intent)
|
handleDeepLink(intent)
|
||||||
|
|
||||||
// Request notification permission and setup FCM
|
// Request notification permission and setup FCM
|
||||||
setupFCM()
|
setupFCM()
|
||||||
|
|
||||||
|
// Verify subscriptions if user is authenticated
|
||||||
|
verifySubscriptionsOnLaunch()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
deepLinkResetToken = deepLinkResetToken,
|
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() {
|
private fun setupFCM() {
|
||||||
// Request notification permission if needed
|
// Request notification permission if needed
|
||||||
if (!FCMManager.isNotificationPermissionGranted(this)) {
|
if (!FCMManager.isNotificationPermissionGranted(this)) {
|
||||||
@@ -86,9 +124,15 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
|||||||
val authToken = TokenStorage.getToken()
|
val authToken = TokenStorage.getToken()
|
||||||
if (authToken != null) {
|
if (authToken != null) {
|
||||||
val notificationApi = com.example.casera.network.NotificationApi()
|
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(
|
val request = com.example.casera.models.DeviceRegistrationRequest(
|
||||||
|
deviceId = deviceId,
|
||||||
registrationId = fcmToken,
|
registrationId = fcmToken,
|
||||||
platform = "android"
|
platform = "android",
|
||||||
|
name = android.os.Build.MODEL
|
||||||
)
|
)
|
||||||
|
|
||||||
when (val result = notificationApi.registerDevice(authToken, request)) {
|
when (val result = notificationApi.registerDevice(authToken, request)) {
|
||||||
|
|||||||
@@ -33,9 +33,15 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
val authToken = com.example.casera.storage.TokenStorage.getToken()
|
val authToken = com.example.casera.storage.TokenStorage.getToken()
|
||||||
if (authToken != null) {
|
if (authToken != null) {
|
||||||
val notificationApi = com.example.casera.network.NotificationApi()
|
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(
|
val request = com.example.casera.models.DeviceRegistrationRequest(
|
||||||
|
deviceId = deviceId,
|
||||||
registrationId = token,
|
registrationId = token,
|
||||||
platform = "android"
|
platform = "android",
|
||||||
|
name = android.os.Build.MODEL
|
||||||
)
|
)
|
||||||
|
|
||||||
when (val result = notificationApi.registerDevice(authToken, request)) {
|
when (val result = notificationApi.registerDevice(authToken, request)) {
|
||||||
|
|||||||
@@ -350,6 +350,8 @@ class BillingManager private constructor(private val context: Context) {
|
|||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "No active purchases to restore")
|
Log.d(TAG, "No active purchases to restore")
|
||||||
|
// Still fetch subscription status from backend to get free tier limits
|
||||||
|
APILayer.getSubscriptionStatus(forceRefresh = true)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.example.casera.ui.screens.MainScreen
|
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.screens.ProfileScreen
|
||||||
import com.example.casera.ui.theme.MyCribTheme
|
import com.example.casera.ui.theme.MyCribTheme
|
||||||
import com.example.casera.ui.theme.ThemeManager
|
import com.example.casera.ui.theme.ThemeManager
|
||||||
@@ -544,6 +545,17 @@ fun App(
|
|||||||
navController.navigate(LoginRoute) {
|
navController.navigate(LoginRoute) {
|
||||||
popUpTo<ProfileRoute> { inclusive = true }
|
popUpTo<ProfileRoute> { inclusive = true }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onNavigateToNotificationPreferences = {
|
||||||
|
navController.navigate(NotificationPreferencesRoute)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable<NotificationPreferencesRoute> {
|
||||||
|
NotificationPreferencesScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DeviceRegistrationRequest(
|
data class DeviceRegistrationRequest(
|
||||||
|
@SerialName("device_id")
|
||||||
|
val deviceId: String,
|
||||||
@SerialName("registration_id")
|
@SerialName("registration_id")
|
||||||
val registrationId: String,
|
val registrationId: String,
|
||||||
val platform: String // "android" or "ios"
|
val platform: String, // "android" or "ios"
|
||||||
|
val name: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DeviceRegistrationResponse(
|
data class DeviceRegistrationResponse(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
|
val name: String? = null,
|
||||||
|
@SerialName("device_id")
|
||||||
|
val deviceId: String,
|
||||||
@SerialName("registration_id")
|
@SerialName("registration_id")
|
||||||
val registrationId: String,
|
val registrationId: String,
|
||||||
val platform: String,
|
val platform: String,
|
||||||
@@ -23,7 +29,6 @@ data class DeviceRegistrationResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class NotificationPreference(
|
data class NotificationPreference(
|
||||||
val id: Int,
|
|
||||||
@SerialName("task_due_soon")
|
@SerialName("task_due_soon")
|
||||||
val taskDueSoon: Boolean = true,
|
val taskDueSoon: Boolean = true,
|
||||||
@SerialName("task_overdue")
|
@SerialName("task_overdue")
|
||||||
@@ -35,11 +40,7 @@ data class NotificationPreference(
|
|||||||
@SerialName("residence_shared")
|
@SerialName("residence_shared")
|
||||||
val residenceShared: Boolean = true,
|
val residenceShared: Boolean = true,
|
||||||
@SerialName("warranty_expiring")
|
@SerialName("warranty_expiring")
|
||||||
val warrantyExpiring: Boolean = true,
|
val warrantyExpiring: Boolean = true
|
||||||
@SerialName("created_at")
|
|
||||||
val createdAt: String,
|
|
||||||
@SerialName("updated_at")
|
|
||||||
val updatedAt: String
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -115,3 +115,6 @@ object VerifyResetCodeRoute
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
object ResetPasswordRoute
|
object ResetPasswordRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object NotificationPreferencesRoute
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ object ApiConfig {
|
|||||||
fun getBaseUrl(): String {
|
fun getBaseUrl(): String {
|
||||||
return when (CURRENT_ENV) {
|
return when (CURRENT_ENV) {
|
||||||
Environment.LOCAL -> "http://${getLocalhostAddress()}:8000/api"
|
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 {
|
fun getMediaBaseUrl(): String {
|
||||||
return when (CURRENT_ENV) {
|
return when (CURRENT_ENV) {
|
||||||
Environment.LOCAL -> "http://${getLocalhostAddress()}:8000"
|
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 {
|
fun getEnvironmentName(): String {
|
||||||
return when (CURRENT_ENV) {
|
return when (CURRENT_ENV) {
|
||||||
Environment.LOCAL -> "Local (${getLocalhostAddress()}:8000)"
|
Environment.LOCAL -> "Local (${getLocalhostAddress()}:8000)"
|
||||||
Environment.DEV -> "Dev Server (mycrib.treytartt.com)"
|
Environment.DEV -> "Dev Server (casera.treytartt.com)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
*/
|
*/
|
||||||
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
|
suspend fun getNotificationPreferences(token: String): ApiResult<NotificationPreference> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/notifications/preferences/my_preferences/") {
|
val response = client.get("$baseUrl/notifications/preferences/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ class NotificationApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
request: UpdateNotificationPreferencesRequest
|
request: UpdateNotificationPreferencesRequest
|
||||||
): ApiResult<NotificationPreference> {
|
): ApiResult<NotificationPreference> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.put("$baseUrl/notifications/preferences/update_preferences/") {
|
val response = client.put("$baseUrl/notifications/preferences/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
|
|||||||
@@ -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(
|
suspend fun verifyIOSReceipt(
|
||||||
token: String,
|
token: String,
|
||||||
receiptData: String,
|
receiptData: String,
|
||||||
transactionId: String
|
transactionId: String
|
||||||
): ApiResult<VerificationResponse> {
|
): ApiResult<VerificationResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/subscription/verify-ios/") {
|
val response = client.post("$baseUrl/subscription/purchase/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(mapOf(
|
setBody(mapOf(
|
||||||
|
"platform" to "ios",
|
||||||
"receipt_data" to receiptData,
|
"receipt_data" to receiptData,
|
||||||
"transaction_id" to transactionId
|
"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(
|
suspend fun verifyAndroidPurchase(
|
||||||
token: String,
|
token: String,
|
||||||
purchaseToken: String,
|
purchaseToken: String,
|
||||||
productId: String
|
productId: String
|
||||||
): ApiResult<VerificationResponse> {
|
): ApiResult<VerificationResponse> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.post("$baseUrl/subscription/verify-android/") {
|
val response = client.post("$baseUrl/subscription/purchase/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(mapOf(
|
setBody(mapOf(
|
||||||
|
"platform" to "android",
|
||||||
"purchase_token" to purchaseToken,
|
"purchase_token" to purchaseToken,
|
||||||
"product_id" to productId
|
"product_id" to productId
|
||||||
))
|
))
|
||||||
@@ -120,4 +130,39 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,7 +243,20 @@ fun MainScreen(
|
|||||||
selectedTab = 0
|
selectedTab = 0
|
||||||
navController.navigate(MainTabResidencesRoute)
|
navController.navigate(MainTabResidencesRoute)
|
||||||
},
|
},
|
||||||
onLogout = onLogout
|
onLogout = onLogout,
|
||||||
|
onNavigateToNotificationPreferences = {
|
||||||
|
navController.navigate(NotificationPreferencesRoute)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composable<NotificationPreferencesRoute> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
NotificationPreferencesScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue
|
|||||||
fun ProfileScreen(
|
fun ProfileScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onNavigateToNotificationPreferences: () -> Unit = {},
|
||||||
viewModel: AuthViewModel = viewModel { AuthViewModel() }
|
viewModel: AuthViewModel = viewModel { AuthViewModel() }
|
||||||
) {
|
) {
|
||||||
var firstName by remember { mutableStateOf("") }
|
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
|
// Subscription Section - Only show if limitations are enabled
|
||||||
if (currentSubscription?.limitationsEnabled == true) {
|
if (currentSubscription?.limitationsEnabled == true) {
|
||||||
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
|
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
|
||||||
|
|||||||
@@ -253,8 +253,8 @@ private fun SubscriptionProductsSection(
|
|||||||
// Monthly Option
|
// Monthly Option
|
||||||
SubscriptionProductCard(
|
SubscriptionProductCard(
|
||||||
productId = "com.example.casera.pro.monthly",
|
productId = "com.example.casera.pro.monthly",
|
||||||
name = "MyCrib Pro Monthly",
|
name = "Casera Pro Monthly",
|
||||||
price = "$4.99/month",
|
price = "$2.99/month",
|
||||||
description = "Billed monthly",
|
description = "Billed monthly",
|
||||||
savingsBadge = null,
|
savingsBadge = null,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
@@ -265,10 +265,10 @@ private fun SubscriptionProductsSection(
|
|||||||
// Annual Option
|
// Annual Option
|
||||||
SubscriptionProductCard(
|
SubscriptionProductCard(
|
||||||
productId = "com.example.casera.pro.annual",
|
productId = "com.example.casera.pro.annual",
|
||||||
name = "MyCrib Pro Annual",
|
name = "Casera Pro Annual",
|
||||||
price = "$39.99/year",
|
price = "$27.99/year",
|
||||||
description = "Billed annually",
|
description = "Billed annually",
|
||||||
savingsBadge = "Save 33%",
|
savingsBadge = "Save 22%",
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
isProcessing = isProcessing,
|
isProcessing = isProcessing,
|
||||||
onSelect = { onProductSelected("com.example.casera.pro.annual") }
|
onSelect = { onProductSelected("com.example.casera.pro.annual") }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,8 +58,8 @@ Replace stub implementation with full Google Play Billing:
|
|||||||
class BillingManager private constructor(private val context: Context) {
|
class BillingManager private constructor(private val context: Context) {
|
||||||
// Product IDs (match Google Play Console)
|
// Product IDs (match Google Play Console)
|
||||||
private val productIDs = listOf(
|
private val productIDs = listOf(
|
||||||
"com.example.mycrib.pro.monthly",
|
"com.example.casera.pro.monthly",
|
||||||
"com.example.mycrib.pro.annual"
|
"com.example.casera.pro.annual"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BillingClient instance
|
// BillingClient instance
|
||||||
@@ -270,7 +270,7 @@ These files show the iOS implementation to mirror:
|
|||||||
|
|
||||||
## Notes
|
## 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
|
- Backend endpoint `POST /subscription/verify-android/` already exists in SubscriptionApi
|
||||||
- Testing requires Google Play Console setup with test products
|
- Testing requires Google Play Console setup with test products
|
||||||
- Use Google's test cards for sandbox testing
|
- Use Google's test cards for sandbox testing
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
"codeOffers" : [
|
"codeOffers" : [
|
||||||
|
|
||||||
],
|
],
|
||||||
"displayPrice" : "4.99",
|
"displayPrice" : "2.99",
|
||||||
"familyShareable" : false,
|
"familyShareable" : false,
|
||||||
"groupNumber" : 1,
|
"groupNumber" : 1,
|
||||||
"internalID" : "6738711291",
|
"internalID" : "6738711291",
|
||||||
@@ -89,13 +89,13 @@
|
|||||||
"localizations" : [
|
"localizations" : [
|
||||||
{
|
{
|
||||||
"description" : "Unlock unlimited properties, tasks, contractors, and documents",
|
"description" : "Unlock unlimited properties, tasks, contractors, and documents",
|
||||||
"displayName" : "MyCrib Pro Monthly",
|
"displayName" : "Casera Pro Monthly",
|
||||||
"locale" : "en_US"
|
"locale" : "en_US"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"productID" : "com.example.mycrib.pro.monthly",
|
"productID" : "com.example.casera.pro.monthly",
|
||||||
"recurringSubscriptionPeriod" : "P1M",
|
"recurringSubscriptionPeriod" : "P1M",
|
||||||
"referenceName" : "MyCrib Pro Monthly",
|
"referenceName" : "Casera Pro Monthly",
|
||||||
"subscriptionGroupID" : "21517970",
|
"subscriptionGroupID" : "21517970",
|
||||||
"type" : "RecurringSubscription"
|
"type" : "RecurringSubscription"
|
||||||
},
|
},
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
"codeOffers" : [
|
"codeOffers" : [
|
||||||
|
|
||||||
],
|
],
|
||||||
"displayPrice" : "49.99",
|
"displayPrice" : "27.99",
|
||||||
"familyShareable" : false,
|
"familyShareable" : false,
|
||||||
"groupNumber" : 2,
|
"groupNumber" : 2,
|
||||||
"internalID" : "6738711458",
|
"internalID" : "6738711458",
|
||||||
@@ -118,14 +118,14 @@
|
|||||||
},
|
},
|
||||||
"localizations" : [
|
"localizations" : [
|
||||||
{
|
{
|
||||||
"description" : "Unlock unlimited properties, tasks, contractors, and documents - Save 17% with annual billing",
|
"description" : "Unlock unlimited properties, tasks, contractors, and documents - Save 22% with annual billing",
|
||||||
"displayName" : "MyCrib Pro Annual",
|
"displayName" : "Casera Pro Annual",
|
||||||
"locale" : "en_US"
|
"locale" : "en_US"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"productID" : "com.example.mycrib.pro.annual",
|
"productID" : "com.example.casera.pro.annual",
|
||||||
"recurringSubscriptionPeriod" : "P1Y",
|
"recurringSubscriptionPeriod" : "P1Y",
|
||||||
"referenceName" : "MyCrib Pro Annual",
|
"referenceName" : "Casera Pro Annual",
|
||||||
"subscriptionGroupID" : "21517970",
|
"subscriptionGroupID" : "21517970",
|
||||||
"type" : "RecurringSubscription"
|
"type" : "RecurringSubscription"
|
||||||
}
|
}
|
||||||
|
|||||||
325
iosApp/iosApp/Profile/NotificationPreferencesView.swift
Normal file
325
iosApp/iosApp/Profile/NotificationPreferencesView.swift
Normal 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()
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ struct ProfileTabView: View {
|
|||||||
@State private var showingThemeSelection = false
|
@State private var showingThemeSelection = false
|
||||||
@State private var showUpgradePrompt = false
|
@State private var showUpgradePrompt = false
|
||||||
@State private var showRestoreSuccess = false
|
@State private var showRestoreSuccess = false
|
||||||
|
@State private var showingNotificationPreferences = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
@@ -42,8 +43,17 @@ struct ProfileTabView: View {
|
|||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink(destination: Text("Notifications")) {
|
Button(action: {
|
||||||
Label("Notifications", systemImage: "bell")
|
showingNotificationPreferences = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Label("Notifications", systemImage: "bell")
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink(destination: Text("Privacy")) {
|
NavigationLink(destination: Text("Privacy")) {
|
||||||
@@ -163,6 +173,9 @@ struct ProfileTabView: View {
|
|||||||
.sheet(isPresented: $showingThemeSelection) {
|
.sheet(isPresented: $showingThemeSelection) {
|
||||||
ThemeSelectionView()
|
ThemeSelectionView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingNotificationPreferences) {
|
||||||
|
NotificationPreferencesView()
|
||||||
|
}
|
||||||
.alert("Log Out", isPresented: $showingLogoutAlert) {
|
.alert("Log Out", isPresented: $showingLogoutAlert) {
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
Button("Log Out", role: .destructive) {
|
Button("Log Out", role: .destructive) {
|
||||||
|
|||||||
@@ -63,10 +63,20 @@ class PushNotificationManager: NSObject, ObservableObject {
|
|||||||
print("⚠️ No auth token available, will register device after login")
|
print("⚠️ No auth token available, will register device after login")
|
||||||
return
|
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(
|
let request = DeviceRegistrationRequest(
|
||||||
|
deviceId: deviceId,
|
||||||
registrationId: token,
|
registrationId: token,
|
||||||
platform: "ios"
|
platform: "ios",
|
||||||
|
name: deviceName
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ class AuthenticationManager: ObservableObject {
|
|||||||
if self.isVerified {
|
if self.isVerified {
|
||||||
_ = try await APILayer.shared.initializeLookups()
|
_ = try await APILayer.shared.initializeLookups()
|
||||||
print("✅ Lookups initialized on app launch for verified user")
|
print("✅ Lookups initialized on app launch for verified user")
|
||||||
|
|
||||||
|
// Verify subscription entitlements with backend
|
||||||
|
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||||
}
|
}
|
||||||
} else if result is ApiResultError {
|
} else if result is ApiResultError {
|
||||||
// Token is invalid, clear it
|
// Token is invalid, clear it
|
||||||
@@ -72,6 +75,9 @@ class AuthenticationManager: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
_ = try await APILayer.shared.initializeLookups()
|
_ = try await APILayer.shared.initializeLookups()
|
||||||
print("✅ Lookups initialized after email verification")
|
print("✅ Lookups initialized after email verification")
|
||||||
|
|
||||||
|
// Verify subscription entitlements with backend
|
||||||
|
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ Failed to initialize lookups after verification: \(error)")
|
print("❌ Failed to initialize lookups after verification: \(error)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,64 @@ class StoreKitManager: ObservableObject {
|
|||||||
print("🔄 StoreKit: Subscription status refreshed")
|
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
|
/// Update purchased product IDs
|
||||||
@MainActor
|
@MainActor
|
||||||
private func updatePurchasedProducts() async {
|
private func updatePurchasedProducts() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user