diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b4bd3d4..b47893f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -52,6 +52,7 @@ kotlin { // implementation(libs.ktor.client.logging) implementation(libs.kotlinx.coroutines.android) + implementation(libs.google.billing) } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/BillingManager.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/BillingManager.kt index 5f1993e..1bf2d5e 100644 --- a/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/BillingManager.kt +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/BillingManager.kt @@ -2,16 +2,28 @@ package com.example.mycrib.platform import android.app.Activity import android.content.Context +import android.util.Log +import com.android.billingclient.api.* +import com.example.mycrib.cache.SubscriptionCache +import com.example.mycrib.network.APILayer +import com.example.mycrib.network.ApiResult +import com.example.mycrib.utils.SubscriptionHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch /** * Google Play Billing manager for in-app purchases - * NOTE: Requires Google Play Console configuration and product IDs + * Handles subscription purchases, verification, and restoration */ -class BillingManager private constructor(context: Context) { +class BillingManager private constructor(private val context: Context) { companion object { + private const val TAG = "BillingManager" + @Volatile private var INSTANCE: BillingManager? = null @@ -22,130 +34,407 @@ class BillingManager private constructor(context: Context) { } } - // Product ID for Pro subscription (configure in Google Play Console) - private val proSubscriptionProductID = "com.example.mycrib.pro.monthly" + // Product IDs (must match Google Play Console) + private val productIDs = listOf( + "com.example.mycrib.pro.monthly", + "com.example.mycrib.pro.annual" + ) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + // StateFlows for UI observation private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading + private val _products = MutableStateFlow>(emptyList()) + val products: StateFlow> = _products + private val _purchasedProductIDs = MutableStateFlow>(emptySet()) val purchasedProductIDs: StateFlow> = _purchasedProductIDs + private val _purchaseError = MutableStateFlow(null) + val purchaseError: StateFlow = _purchaseError + + private val _connectionState = MutableStateFlow(false) + val isConnected: StateFlow = _connectionState + + private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> + when (billingResult.responseCode) { + BillingClient.BillingResponseCode.OK -> { + purchases?.forEach { purchase -> + handlePurchase(purchase) + } + } + BillingClient.BillingResponseCode.USER_CANCELED -> { + Log.d(TAG, "User canceled purchase") + _purchaseError.value = null // Not really an error + } + else -> { + val errorMessage = "Purchase failed: ${billingResult.debugMessage}" + Log.e(TAG, errorMessage) + _purchaseError.value = errorMessage + } + } + _isLoading.value = false + } + + private val billingClient: BillingClient = BillingClient.newBuilder(context) + .setListener(purchasesUpdatedListener) + .enablePendingPurchases() + .build() + init { - // Start listening for purchases - // In production, initialize BillingClient here - println("BillingManager: Initialized") + Log.d(TAG, "BillingManager initialized") } /** * Connect to Google Play Billing */ - fun startConnection(onSuccess: () -> Unit, onError: (String) -> Unit) { - // In production, this would connect to BillingClient - // billingClient.startConnection(object : BillingClientStateListener { - // override fun onBillingSetupFinished(billingResult: BillingResult) { - // if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - // onSuccess() - // } else { - // onError("Billing setup failed") - // } - // } - // override fun onBillingServiceDisconnected() { /* Retry */ } - // }) + fun startConnection(onSuccess: () -> Unit = {}, onError: (String) -> Unit = {}) { + if (billingClient.isReady) { + Log.d(TAG, "Already connected to billing") + onSuccess() + return + } - println("BillingManager: Would connect to Google Play Billing") - onSuccess() + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "Billing connection successful") + _connectionState.value = true + onSuccess() + } else { + val error = "Billing setup failed: ${billingResult.debugMessage}" + Log.e(TAG, error) + _connectionState.value = false + onError(error) + } + } + + override fun onBillingServiceDisconnected() { + Log.w(TAG, "Billing service disconnected, will retry") + _connectionState.value = false + // Retry connection after a delay + scope.launch { + kotlinx.coroutines.delay(3000) + startConnection(onSuccess, onError) + } + } + }) } /** - * Query available products + * Query available subscription products from Google Play */ - suspend fun queryProducts(): List { + suspend fun loadProducts() { + if (!billingClient.isReady) { + Log.w(TAG, "Billing client not ready, cannot load products") + return + } + _isLoading.value = true try { - // In production, this would query real products - // val params = QueryProductDetailsParams.newBuilder() - // .setProductList(listOf( - // Product.newBuilder() - // .setProductId(proSubscriptionProductID) - // .setProductType(BillingClient.ProductType.SUBS) - // .build() - // )) - // .build() - // val result = billingClient.queryProductDetails(params) + val productList = productIDs.map { productId -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(productId) + .setProductType(BillingClient.ProductType.SUBS) + .build() + } - println("BillingManager: Would query products here") - return emptyList() + val params = QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + + val result = billingClient.queryProductDetails(params) + + if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + _products.value = result.productDetailsList ?: emptyList() + Log.d(TAG, "Loaded ${_products.value.size} products") + + // Log product details for debugging + _products.value.forEach { product -> + Log.d(TAG, "Product: ${product.productId} - ${product.title}") + product.subscriptionOfferDetails?.forEach { offer -> + Log.d(TAG, " Offer: ${offer.basePlanId} - ${offer.pricingPhases.pricingPhaseList.firstOrNull()?.formattedPrice}") + } + } + } else { + Log.e(TAG, "Failed to load products: ${result.billingResult.debugMessage}") + } + } catch (e: Exception) { + Log.e(TAG, "Error loading products", e) } finally { _isLoading.value = false } } /** - * Launch purchase flow + * Launch purchase flow for a subscription */ - fun launchPurchaseFlow(activity: Activity, productId: String, onSuccess: () -> Unit, onError: (String) -> Unit) { - // In production, this would launch the purchase UI - // val params = BillingFlowParams.newBuilder() - // .setProductDetailsParamsList(listOf(productDetailsParams)) - // .build() - // val billingResult = billingClient.launchBillingFlow(activity, params) + fun launchPurchaseFlow( + activity: Activity, + productDetails: ProductDetails, + onSuccess: () -> Unit = {}, + onError: (String) -> Unit = {} + ) { + if (!billingClient.isReady) { + onError("Billing not ready") + return + } - println("BillingManager: Would launch purchase flow for: $productId") - onError("Purchase not implemented yet") + val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken + if (offerToken == null) { + onError("No offer available for this product") + return + } + + _isLoading.value = true + _purchaseError.value = null + + val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken) + .build() + + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productDetailsParams)) + .build() + + val billingResult = billingClient.launchBillingFlow(activity, billingFlowParams) + + if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { + _isLoading.value = false + val error = "Failed to launch purchase: ${billingResult.debugMessage}" + Log.e(TAG, error) + onError(error) + } + // Note: Success/failure is handled in purchasesUpdatedListener } /** - * Verify purchase with backend + * Handle a completed purchase */ - suspend fun verifyPurchaseWithBackend(purchaseToken: String, productId: String) { - // TODO: Call backend API to verify purchase - // val api = SubscriptionApi() - // val result = api.verifyAndroidPurchase( - // token = tokenStorage.getToken(), - // purchaseToken = purchaseToken, - // productId = productId - // ) + private fun handlePurchase(purchase: Purchase) { + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + Log.d(TAG, "Purchase successful: ${purchase.products}") - println("BillingManager: Would verify purchase with backend") + // Verify with backend and acknowledge + scope.launch { + try { + // Verify purchase with backend + val verified = verifyPurchaseWithBackend( + purchaseToken = purchase.purchaseToken, + productId = purchase.products.firstOrNull() ?: "" + ) + + if (verified) { + // Acknowledge if not already acknowledged + if (!purchase.isAcknowledged) { + acknowledgePurchase(purchase.purchaseToken) + } + + // Update local state + _purchasedProductIDs.value = _purchasedProductIDs.value + purchase.products.toSet() + + // Update subscription tier + SubscriptionHelper.currentTier = "pro" + + // Refresh subscription status from backend + APILayer.getSubscriptionStatus(forceRefresh = true) + + Log.d(TAG, "Purchase verified and acknowledged") + } else { + _purchaseError.value = "Failed to verify purchase with server" + } + } catch (e: Exception) { + Log.e(TAG, "Error handling purchase", e) + _purchaseError.value = "Error processing purchase: ${e.message}" + } + } + } else if (purchase.purchaseState == Purchase.PurchaseState.PENDING) { + Log.d(TAG, "Purchase pending: ${purchase.products}") + // Handle pending purchases (e.g., waiting for payment method) + } } /** - * Restore purchases + * Verify purchase with backend server */ - suspend fun restorePurchases() { - // In production, this would query purchase history - // val result = billingClient.queryPurchasesAsync( - // QueryPurchasesParams.newBuilder() - // .setProductType(BillingClient.ProductType.SUBS) - // .build() - // ) + private suspend fun verifyPurchaseWithBackend(purchaseToken: String, productId: String): Boolean { + return try { + val result = APILayer.verifyAndroidPurchase( + purchaseToken = purchaseToken, + productId = productId + ) - println("BillingManager: Would restore purchases here") + when (result) { + is ApiResult.Success -> { + Log.d(TAG, "Backend verification successful: tier=${result.data.tier}") + result.data.success + } + is ApiResult.Error -> { + Log.e(TAG, "Backend verification failed: ${result.message}") + false + } + else -> false + } + } catch (e: Exception) { + Log.e(TAG, "Error verifying purchase with backend", e) + false + } } /** - * Acknowledge purchase (required by Google Play) + * Acknowledge purchase (required by Google Play - purchases refund after 3 days if not acknowledged) */ private suspend fun acknowledgePurchase(purchaseToken: String) { - // In production, this would acknowledge the purchase - // val params = AcknowledgePurchaseParams.newBuilder() - // .setPurchaseToken(purchaseToken) - // .build() - // billingClient.acknowledgePurchase(params) + val params = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchaseToken) + .build() - println("BillingManager: Would acknowledge purchase") + val result = billingClient.acknowledgePurchase(params) + + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(TAG, "Purchase acknowledged") + } else { + Log.e(TAG, "Failed to acknowledge purchase: ${result.debugMessage}") + } + } + + /** + * Restore purchases (check for existing subscriptions) + */ + suspend fun restorePurchases(): Boolean { + if (!billingClient.isReady) { + Log.w(TAG, "Billing client not ready, cannot restore purchases") + return false + } + + _isLoading.value = true + + return try { + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + val result = billingClient.queryPurchasesAsync(params) + + if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + val activePurchases = result.purchasesList.filter { + it.purchaseState == Purchase.PurchaseState.PURCHASED + } + + Log.d(TAG, "Found ${activePurchases.size} active purchases") + + if (activePurchases.isNotEmpty()) { + // Update purchased products + _purchasedProductIDs.value = activePurchases + .flatMap { it.products } + .toSet() + + // Verify each with backend + activePurchases.forEach { purchase -> + verifyPurchaseWithBackend( + purchaseToken = purchase.purchaseToken, + productId = purchase.products.firstOrNull() ?: "" + ) + } + + // Update subscription tier + SubscriptionHelper.currentTier = "pro" + + // Refresh subscription status from backend + APILayer.getSubscriptionStatus(forceRefresh = true) + + true + } else { + Log.d(TAG, "No active purchases to restore") + false + } + } else { + Log.e(TAG, "Failed to query purchases: ${result.billingResult.debugMessage}") + false + } + } catch (e: Exception) { + Log.e(TAG, "Error restoring purchases", e) + false + } finally { + _isLoading.value = false + } + } + + /** + * Clear error state + */ + fun clearError() { + _purchaseError.value = null + } + + /** + * Get formatted price for a product + */ + fun getFormattedPrice(productDetails: ProductDetails): String? { + return productDetails.subscriptionOfferDetails + ?.firstOrNull() + ?.pricingPhases + ?.pricingPhaseList + ?.firstOrNull() + ?.formattedPrice + } + + /** + * Calculate savings percentage for annual vs monthly + */ + fun calculateAnnualSavings(monthly: ProductDetails?, annual: ProductDetails?): Int? { + if (monthly == null || annual == null) return null + + val monthlyPrice = monthly.subscriptionOfferDetails + ?.firstOrNull() + ?.pricingPhases + ?.pricingPhaseList + ?.firstOrNull() + ?.priceAmountMicros ?: return null + + val annualPrice = annual.subscriptionOfferDetails + ?.firstOrNull() + ?.pricingPhases + ?.pricingPhaseList + ?.firstOrNull() + ?.priceAmountMicros ?: return null + + // Calculate what 12 months would cost vs annual price + val yearlyAtMonthlyRate = monthlyPrice * 12 + val savings = ((yearlyAtMonthlyRate - annualPrice).toDouble() / yearlyAtMonthlyRate * 100).toInt() + + return if (savings > 0) savings else null + } + + /** + * Get product by ID + */ + fun getProduct(productId: String): ProductDetails? { + return _products.value.find { it.productId == productId } + } + + /** + * Get monthly product + */ + fun getMonthlyProduct(): ProductDetails? { + return _products.value.find { it.productId == "com.example.mycrib.pro.monthly" } + } + + /** + * Get annual product + */ + fun getAnnualProduct(): ProductDetails? { + return _products.value.find { it.productId == "com.example.mycrib.pro.annual" } + } + + /** + * Check if user has an active subscription + */ + fun hasActiveSubscription(): Boolean { + return _purchasedProductIDs.value.isNotEmpty() } } - -/** - * Placeholder for ProductDetails - * In production, use com.android.billingclient.api.ProductDetails - */ -data class ProductDetails( - val productId: String, - val title: String, - val description: String, - val price: String -) diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreenAndroid.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreenAndroid.kt new file mode 100644 index 0000000..1dbb57a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreenAndroid.kt @@ -0,0 +1,422 @@ +package com.example.mycrib.ui.subscription + +import android.app.Activity +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.android.billingclient.api.ProductDetails +import com.example.mycrib.cache.SubscriptionCache +import com.example.mycrib.platform.BillingManager +import com.example.mycrib.ui.theme.AppSpacing +import kotlinx.coroutines.launch + +/** + * Android-specific upgrade screen that connects to Google Play Billing. + * This version shows real product pricing from Google Play Console. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpgradeFeatureScreenAndroid( + triggerKey: String, + icon: ImageVector, + billingManager: BillingManager, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val activity = context as? Activity + val scope = rememberCoroutineScope() + + var showFeatureComparison by remember { mutableStateOf(false) } + var selectedProductId by remember { mutableStateOf(null) } + var showSuccessAlert by remember { mutableStateOf(false) } + + // Observe billing manager state + val isLoading by billingManager.isLoading.collectAsState() + val products by billingManager.products.collectAsState() + val purchaseError by billingManager.purchaseError.collectAsState() + val purchasedProductIDs by billingManager.purchasedProductIDs.collectAsState() + + // Look up trigger data from cache + val triggerData by remember { derivedStateOf { + SubscriptionCache.upgradeTriggers.value[triggerKey] + } } + + // Fallback values if trigger not found + val title = triggerData?.title ?: "Upgrade Required" + val message = triggerData?.message ?: "This feature is available with a Pro subscription." + + // Load products on launch + LaunchedEffect(Unit) { + billingManager.loadProducts() + } + + // Check for successful purchase + LaunchedEffect(purchasedProductIDs) { + if (purchasedProductIDs.isNotEmpty()) { + showSuccessAlert = true + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(title, fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(AppSpacing.xl)) + + // Feature Icon + Icon( + imageVector = Icons.Default.Stars, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + + Spacer(Modifier.height(AppSpacing.lg)) + + // Title + Text( + title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = AppSpacing.lg) + ) + + Spacer(Modifier.height(AppSpacing.md)) + + // Description + Text( + message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = AppSpacing.lg) + ) + + Spacer(Modifier.height(AppSpacing.xl)) + + // Pro Features Preview Card + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.lg), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Column( + modifier = Modifier.padding(AppSpacing.lg), + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + FeatureRowAndroid(Icons.Default.Home, "Unlimited properties") + FeatureRowAndroid(Icons.Default.CheckCircle, "Unlimited tasks") + FeatureRowAndroid(Icons.Default.People, "Contractor management") + FeatureRowAndroid(Icons.Default.Description, "Document & warranty storage") + } + } + + Spacer(Modifier.height(AppSpacing.xl)) + + // Subscription Products Section + if (isLoading && products.isEmpty()) { + CircularProgressIndicator( + modifier = Modifier.padding(AppSpacing.lg), + color = MaterialTheme.colorScheme.primary + ) + } else if (products.isNotEmpty()) { + // Calculate savings for annual + val monthlyProduct = billingManager.getMonthlyProduct() + val annualProduct = billingManager.getAnnualProduct() + val annualSavings = billingManager.calculateAnnualSavings(monthlyProduct, annualProduct) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.lg), + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + products.forEach { product -> + val isAnnual = product.productId.contains("annual") + val savingsBadge = if (isAnnual && annualSavings != null) { + "Save $annualSavings%" + } else null + + SubscriptionProductCardAndroid( + productDetails = product, + formattedPrice = billingManager.getFormattedPrice(product) ?: "Loading...", + savingsBadge = savingsBadge, + isSelected = selectedProductId == product.productId, + isProcessing = isLoading && selectedProductId == product.productId, + onSelect = { + selectedProductId = product.productId + activity?.let { act -> + billingManager.launchPurchaseFlow( + activity = act, + productDetails = product, + onSuccess = { showSuccessAlert = true }, + onError = { /* Error shown via purchaseError flow */ } + ) + } + } + ) + } + } + } else { + // Fallback if no products loaded + Button( + onClick = { + scope.launch { + billingManager.loadProducts() + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.lg) + ) { + Text("Retry Loading Products") + } + } + + // Error Message + purchaseError?.let { error -> + Spacer(Modifier.height(AppSpacing.md)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.lg), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + ) { + Row( + modifier = Modifier.padding(AppSpacing.md), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { billingManager.clearError() }, + modifier = Modifier.size(24.dp) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Dismiss", + modifier = Modifier.size(16.dp) + ) + } + } + } + } + + Spacer(Modifier.height(AppSpacing.lg)) + + // Compare Plans + TextButton(onClick = { showFeatureComparison = true }) { + Text("Compare Free vs Pro") + } + + // Restore Purchases + TextButton(onClick = { + scope.launch { + val restored = billingManager.restorePurchases() + if (restored) { + showSuccessAlert = true + } + } + }) { + Text( + "Restore Purchases", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(Modifier.height(AppSpacing.xl * 2)) + } + + if (showFeatureComparison) { + FeatureComparisonDialog( + onDismiss = { showFeatureComparison = false }, + onUpgrade = { + showFeatureComparison = false + // Select first product if available + products.firstOrNull()?.let { product -> + selectedProductId = product.productId + activity?.let { act -> + billingManager.launchPurchaseFlow( + activity = act, + productDetails = product, + onSuccess = { showSuccessAlert = true }, + onError = { } + ) + } + } + } + ) + } + + if (showSuccessAlert) { + AlertDialog( + onDismissRequest = { + showSuccessAlert = false + onNavigateBack() + }, + title = { Text("Subscription Active") }, + text = { Text("You now have full access to all Pro features!") }, + confirmButton = { + TextButton(onClick = { + showSuccessAlert = false + onNavigateBack() + }) { + Text("Done") + } + } + ) + } + } +} + +@Composable +private fun FeatureRowAndroid(icon: ImageVector, text: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun SubscriptionProductCardAndroid( + productDetails: ProductDetails, + formattedPrice: String, + savingsBadge: String?, + isSelected: Boolean, + isProcessing: Boolean, + onSelect: () -> Unit +) { + val isAnnual = productDetails.productId.contains("annual") + val productName = if (isAnnual) "MyCrib Pro Annual" else "MyCrib Pro Monthly" + val billingPeriod = if (isAnnual) "Billed annually" else "Billed monthly" + + Card( + onClick = onSelect, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surface + ), + border = if (isSelected) + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + else + null + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpacing.lg), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + productName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + savingsBadge?.let { badge -> + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.tertiaryContainer + ) { + Text( + badge, + modifier = Modifier.padding( + horizontal = AppSpacing.sm, + vertical = 2.dp + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Bold + ) + } + } + } + Text( + billingPeriod, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (isProcessing) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Text( + formattedPrice, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/cache/DataCache.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/cache/DataCache.kt index 5b8d23c..a3ce7ba 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/cache/DataCache.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/cache/DataCache.kt @@ -48,7 +48,7 @@ object DataCache { private val _contractors = MutableStateFlow>(emptyList()) val contractors: StateFlow> = _contractors.asStateFlow() - // Lookups/Reference Data + // Lookups/Reference Data - List-based (for dropdowns/pickers) private val _residenceTypes = MutableStateFlow>(emptyList()) val residenceTypes: StateFlow> = _residenceTypes.asStateFlow() @@ -67,9 +67,36 @@ object DataCache { private val _contractorSpecialties = MutableStateFlow>(emptyList()) val contractorSpecialties: StateFlow> = _contractorSpecialties.asStateFlow() + // Lookups/Reference Data - Map-based (for O(1) ID resolution) + private val _residenceTypesMap = MutableStateFlow>(emptyMap()) + val residenceTypesMap: StateFlow> = _residenceTypesMap.asStateFlow() + + private val _taskFrequenciesMap = MutableStateFlow>(emptyMap()) + val taskFrequenciesMap: StateFlow> = _taskFrequenciesMap.asStateFlow() + + private val _taskPrioritiesMap = MutableStateFlow>(emptyMap()) + val taskPrioritiesMap: StateFlow> = _taskPrioritiesMap.asStateFlow() + + private val _taskStatusesMap = MutableStateFlow>(emptyMap()) + val taskStatusesMap: StateFlow> = _taskStatusesMap.asStateFlow() + + private val _taskCategoriesMap = MutableStateFlow>(emptyMap()) + val taskCategoriesMap: StateFlow> = _taskCategoriesMap.asStateFlow() + + private val _contractorSpecialtiesMap = MutableStateFlow>(emptyMap()) + val contractorSpecialtiesMap: StateFlow> = _contractorSpecialtiesMap.asStateFlow() + private val _lookupsInitialized = MutableStateFlow(false) val lookupsInitialized: StateFlow = _lookupsInitialized.asStateFlow() + // O(1) lookup helper methods - resolve ID to full object + fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] } + fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] } + fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] } + fun getTaskStatus(id: Int?): TaskStatus? = id?.let { _taskStatusesMap.value[it] } + fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] } + fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] } + // Cache metadata private val _lastRefreshTime = MutableStateFlow(0L) val lastRefreshTime: StateFlow = _lastRefreshTime.asStateFlow() @@ -177,38 +204,50 @@ object DataCache { _contractors.value = _contractors.value.filter { it.id != contractorId } } - // Lookup update methods + // Lookup update methods - update both list and map versions fun updateResidenceTypes(types: List) { _residenceTypes.value = types + _residenceTypesMap.value = types.associateBy { it.id } } fun updateTaskFrequencies(frequencies: List) { _taskFrequencies.value = frequencies + _taskFrequenciesMap.value = frequencies.associateBy { it.id } } fun updateTaskPriorities(priorities: List) { _taskPriorities.value = priorities + _taskPrioritiesMap.value = priorities.associateBy { it.id } } fun updateTaskStatuses(statuses: List) { _taskStatuses.value = statuses + _taskStatusesMap.value = statuses.associateBy { it.id } } fun updateTaskCategories(categories: List) { _taskCategories.value = categories + _taskCategoriesMap.value = categories.associateBy { it.id } } fun updateContractorSpecialties(specialties: List) { _contractorSpecialties.value = specialties + _contractorSpecialtiesMap.value = specialties.associateBy { it.id } } fun updateAllLookups(staticData: StaticDataResponse) { _residenceTypes.value = staticData.residenceTypes + _residenceTypesMap.value = staticData.residenceTypes.associateBy { it.id } _taskFrequencies.value = staticData.taskFrequencies + _taskFrequenciesMap.value = staticData.taskFrequencies.associateBy { it.id } _taskPriorities.value = staticData.taskPriorities + _taskPrioritiesMap.value = staticData.taskPriorities.associateBy { it.id } _taskStatuses.value = staticData.taskStatuses + _taskStatusesMap.value = staticData.taskStatuses.associateBy { it.id } _taskCategories.value = staticData.taskCategories + _taskCategoriesMap.value = staticData.taskCategories.associateBy { it.id } _contractorSpecialties.value = staticData.contractorSpecialties + _contractorSpecialtiesMap.value = staticData.contractorSpecialties.associateBy { it.id } } fun markLookupsInitialized() { @@ -233,11 +272,17 @@ object DataCache { fun clearLookups() { _residenceTypes.value = emptyList() + _residenceTypesMap.value = emptyMap() _taskFrequencies.value = emptyList() + _taskFrequenciesMap.value = emptyMap() _taskPriorities.value = emptyList() + _taskPrioritiesMap.value = emptyMap() _taskStatuses.value = emptyList() + _taskStatusesMap.value = emptyMap() _taskCategories.value = emptyList() + _taskCategoriesMap.value = emptyMap() _contractorSpecialties.value = emptyList() + _contractorSpecialtiesMap.value = emptyMap() _lookupsInitialized.value = false } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Contractor.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Contractor.kt index f9df58a..b9e530e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Contractor.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Contractor.kt @@ -79,5 +79,23 @@ data class ContractorSummary( @SerialName("task_count") val taskCount: Int = 0 ) +/** + * Minimal contractor model for list views. + * Uses specialty_id instead of nested specialty object. + * Resolve via DataCache.getContractorSpecialty(contractor.specialtyId) + */ +@Serializable +data class ContractorMinimal( + val id: Int, + val name: String, + val company: String? = null, + val phone: String? = null, + @SerialName("specialty_id") val specialtyId: Int? = null, + @SerialName("average_rating") val averageRating: Double? = null, + @SerialName("is_favorite") val isFavorite: Boolean = false, + @SerialName("task_count") val taskCount: Int = 0, + @SerialName("last_used") val lastUsed: String? = null +) + // Removed: ContractorListResponse - no longer using paginated responses -// API now returns List directly +// API now returns List directly from list endpoint diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt index 08f2b35..a6ecd9f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt @@ -112,6 +112,37 @@ data class TaskCancelResponse( val task: TaskDetail ) +/** + * Request model for PATCH updates to a task. + * Used for status changes and archive/unarchive operations. + * All fields are optional - only provided fields will be updated. + */ +@Serializable +data class TaskPatchRequest( + val status: Int? = null, // Status ID to update + val archived: Boolean? = null // Archive/unarchive flag +) + +/** + * Minimal task model for list/kanban views. + * Uses IDs instead of nested objects for efficiency. + * Resolve IDs to full objects via DataCache.getTaskCategory(), etc. + */ +@Serializable +data class TaskMinimal( + val id: Int, + val title: String, + val description: String? = null, + @SerialName("due_date") val dueDate: String? = null, + @SerialName("next_scheduled_date") val nextScheduledDate: String? = null, + @SerialName("category_id") val categoryId: Int? = null, + @SerialName("frequency_id") val frequencyId: Int, + @SerialName("priority_id") val priorityId: Int, + @SerialName("status_id") val statusId: Int? = null, + @SerialName("completion_count") val completionCount: Int? = null, + val archived: Boolean = false +) + @Serializable data class TaskColumn( val name: String, @@ -119,7 +150,7 @@ data class TaskColumn( @SerialName("button_types") val buttonTypes: List, val icons: Map, val color: String, - val tasks: List, + val tasks: List, // Keep using TaskDetail for now - will be TaskMinimal after full migration val count: Int ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt index 7193b5b..4feeb59 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt @@ -155,6 +155,34 @@ data class MyResidencesResponse( val residences: List ) +/** + * Minimal residence model for list views. + * Uses property_type_id and annotated counts instead of nested objects. + * Resolve property type via DataCache.getResidenceType(residence.propertyTypeId) + */ +@Serializable +data class ResidenceMinimal( + val id: Int, + val name: String, + @SerialName("property_type_id") val propertyTypeId: Int? = null, + val bedrooms: Int? = null, + val bathrooms: Float? = null, + @SerialName("is_primary") val isPrimary: Boolean = false, + @SerialName("is_primary_owner") val isPrimaryOwner: Boolean = false, + @SerialName("user_count") val userCount: Int = 1, + // Annotated counts from database (no N+1 queries) + @SerialName("task_count") val taskCount: Int = 0, + @SerialName("tasks_pending") val tasksPending: Int = 0, + @SerialName("tasks_overdue") val tasksOverdue: Int = 0, + @SerialName("tasks_due_week") val tasksDueWeek: Int = 0, + // Reference to last/next task (just ID and date, not full object) + @SerialName("last_completed_task_id") val lastCompletedTaskId: Int? = null, + @SerialName("last_completed_date") val lastCompletedDate: String? = null, + @SerialName("next_task_id") val nextTaskId: Int? = null, + @SerialName("next_task_date") val nextTaskDate: String? = null, + @SerialName("created_at") val createdAt: String +) + // Share Code Models @Serializable data class ResidenceShareCode( diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt index 40d2581..8148ce8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Subscription.kt @@ -33,6 +33,7 @@ data class TierLimits( data class UpgradeTriggerData( val title: String, val message: String, + @SerialName("promo_html") val promoHtml: String? = null, @SerialName("button_text") val buttonText: String ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt index 4b92dfb..84e80d7 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/APILayer.kt @@ -76,13 +76,19 @@ object APILayer { val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) try { - // Load all lookups in parallel - val residenceTypesResult = lookupsApi.getResidenceTypes(token) - val taskFrequenciesResult = lookupsApi.getTaskFrequencies(token) - val taskPrioritiesResult = lookupsApi.getTaskPriorities(token) - val taskStatusesResult = lookupsApi.getTaskStatuses(token) - val taskCategoriesResult = lookupsApi.getTaskCategories(token) - val contractorSpecialtiesResult = lookupsApi.getContractorSpecialties(token) + // Load all lookups in a single API call using static_data endpoint + println("🔄 Fetching static data (all lookups)...") + val staticDataResult = lookupsApi.getStaticData(token) + println("📦 Static data result: $staticDataResult") + + // Update cache with all lookups at once + if (staticDataResult is ApiResult.Success) { + DataCache.updateAllLookups(staticDataResult.data) + println("✅ All lookups loaded successfully") + } else if (staticDataResult is ApiResult.Error) { + println("❌ Failed to fetch static data: ${staticDataResult.message}") + return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}") + } // Load subscription status to get limitationsEnabled, usage, and limits from backend // Note: tier (free/pro) will be updated by StoreKit after receipt verification @@ -95,26 +101,6 @@ object APILayer { val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) println("📦 Upgrade triggers result: $upgradeTriggersResult") - // Update cache with successful results - if (residenceTypesResult is ApiResult.Success) { - DataCache.updateResidenceTypes(residenceTypesResult.data) - } - if (taskFrequenciesResult is ApiResult.Success) { - DataCache.updateTaskFrequencies(taskFrequenciesResult.data) - } - if (taskPrioritiesResult is ApiResult.Success) { - DataCache.updateTaskPriorities(taskPrioritiesResult.data) - } - if (taskStatusesResult is ApiResult.Success) { - DataCache.updateTaskStatuses(taskStatusesResult.data) - } - if (taskCategoriesResult is ApiResult.Success) { - DataCache.updateTaskCategories(taskCategoriesResult.data) - } - if (contractorSpecialtiesResult is ApiResult.Success) { - DataCache.updateContractorSpecialties(contractorSpecialtiesResult.data) - } - if (subscriptionStatusResult is ApiResult.Success) { println("✅ Updating subscription cache with: ${subscriptionStatusResult.data}") SubscriptionCache.updateSubscriptionStatus(subscriptionStatusResult.data) @@ -474,9 +460,24 @@ object APILayer { return result } - suspend fun cancelTask(taskId: Int): ApiResult { + /** + * Get status ID by name from DataCache. + * Falls back to a default ID if status not found. + */ + private fun getStatusIdByName(name: String): Int? { + return DataCache.taskStatuses.value.find { + it.name.equals(name, ignoreCase = true) + }?.id + } + + suspend fun cancelTask(taskId: Int): ApiResult { val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) - val result = taskApi.cancelTask(token, taskId) + + // Look up 'cancelled' status ID from cache + val cancelledStatusId = getStatusIdByName("cancelled") + ?: return ApiResult.Error("Cancelled status not found in cache") + + val result = taskApi.cancelTask(token, taskId, cancelledStatusId) // Refresh tasks cache on success if (result is ApiResult.Success) { @@ -486,9 +487,14 @@ object APILayer { return result } - suspend fun uncancelTask(taskId: Int): ApiResult { + suspend fun uncancelTask(taskId: Int): ApiResult { val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) - val result = taskApi.uncancelTask(token, taskId) + + // Look up 'pending' status ID from cache + val pendingStatusId = getStatusIdByName("pending") + ?: return ApiResult.Error("Pending status not found in cache") + + val result = taskApi.uncancelTask(token, taskId, pendingStatusId) // Refresh tasks cache on success if (result is ApiResult.Success) { @@ -498,9 +504,15 @@ object APILayer { return result } - suspend fun markInProgress(taskId: Int): ApiResult { + suspend fun markInProgress(taskId: Int): ApiResult { val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) - val result = taskApi.markInProgress(token, taskId) + + // Look up 'in progress' status ID from cache + val inProgressStatusId = getStatusIdByName("in progress") + ?: getStatusIdByName("In Progress") // Try alternate casing + ?: return ApiResult.Error("In Progress status not found in cache") + + val result = taskApi.markInProgress(token, taskId, inProgressStatusId) // Refresh tasks cache on success if (result is ApiResult.Success) { @@ -510,7 +522,7 @@ object APILayer { return result } - suspend fun archiveTask(taskId: Int): ApiResult { + suspend fun archiveTask(taskId: Int): ApiResult { val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.archiveTask(token, taskId) @@ -522,7 +534,7 @@ object APILayer { return result } - suspend fun unarchiveTask(taskId: Int): ApiResult { + suspend fun unarchiveTask(taskId: Int): ApiResult { val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.unarchiveTask(token, taskId) @@ -953,4 +965,37 @@ object APILayer { val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.getUnreadCount(token) } + + // ==================== Subscription Operations ==================== + + /** + * Get subscription status from backend + */ + suspend fun getSubscriptionStatus(forceRefresh: Boolean = false): ApiResult { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val result = subscriptionApi.getSubscriptionStatus(token) + + // Update cache on success + if (result is ApiResult.Success) { + SubscriptionCache.updateSubscriptionStatus(result.data) + } + + return result + } + + /** + * Verify Android purchase with backend + */ + suspend fun verifyAndroidPurchase(purchaseToken: String, productId: String): ApiResult { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return subscriptionApi.verifyAndroidPurchase(token, purchaseToken, productId) + } + + /** + * Verify iOS receipt with backend + */ + suspend fun verifyIOSReceipt(receiptData: String, transactionId: String): ApiResult { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return subscriptionApi.verifyIOSReceipt(token, receiptData, transactionId) + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt index f5859a7..e7bd1a5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskApi.kt @@ -124,10 +124,20 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun cancelTask(token: String, id: Int): ApiResult { + /** + * Generic PATCH method for partial task updates. + * Used for status changes and archive/unarchive operations. + * + * NOTE: The old custom action endpoints (cancel, uncancel, mark-in-progress, + * archive, unarchive) have been REMOVED from the API. + * All task updates now use PATCH /tasks/{id}/. + */ + suspend fun patchTask(token: String, id: Int, request: TaskPatchRequest): ApiResult { return try { - val response = client.post("$baseUrl/tasks/$id/cancel/") { + val response = client.patch("$baseUrl/tasks/$id/") { header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(request) } if (response.status.isSuccess()) { @@ -141,71 +151,27 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun uncancelTask(token: String, id: Int): ApiResult { - return try { - val response = client.post("$baseUrl/tasks/$id/uncancel/") { - header("Authorization", "Token $token") - } + // DEPRECATED: These methods now use PATCH internally. + // They're kept for backward compatibility with existing ViewModel calls. + // New code should use patchTask directly with status IDs from DataCache. - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - val errorMessage = ErrorParser.parseError(response) - ApiResult.Error(errorMessage, response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } + suspend fun cancelTask(token: String, id: Int, cancelledStatusId: Int): ApiResult { + return patchTask(token, id, TaskPatchRequest(status = cancelledStatusId)) } - suspend fun markInProgress(token: String, id: Int): ApiResult { - return try { - val response = client.post("$baseUrl/tasks/$id/mark-in-progress/") { - header("Authorization", "Token $token") - } - - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - val errorMessage = ErrorParser.parseError(response) - ApiResult.Error(errorMessage, response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } + suspend fun uncancelTask(token: String, id: Int, pendingStatusId: Int): ApiResult { + return patchTask(token, id, TaskPatchRequest(status = pendingStatusId)) } - suspend fun archiveTask(token: String, id: Int): ApiResult { - return try { - val response = client.post("$baseUrl/tasks/$id/archive/") { - header("Authorization", "Token $token") - } - - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - val errorMessage = ErrorParser.parseError(response) - ApiResult.Error(errorMessage, response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } + suspend fun markInProgress(token: String, id: Int, inProgressStatusId: Int): ApiResult { + return patchTask(token, id, TaskPatchRequest(status = inProgressStatusId)) } - suspend fun unarchiveTask(token: String, id: Int): ApiResult { - return try { - val response = client.post("$baseUrl/tasks/$id/unarchive/") { - header("Authorization", "Token $token") - } + suspend fun archiveTask(token: String, id: Int): ApiResult { + return patchTask(token, id, TaskPatchRequest(archived = true)) + } - if (response.status.isSuccess()) { - ApiResult.Success(response.body()) - } else { - val errorMessage = ErrorParser.parseError(response) - ApiResult.Error(errorMessage, response.status.value) - } - } catch (e: Exception) { - ApiResult.Error(e.message ?: "Unknown error occurred") - } + suspend fun unarchiveTask(token: String, id: Int): ApiResult { + return patchTask(token, id, TaskPatchRequest(archived = false)) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt index 0623cdb..8ee26ed 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt @@ -27,7 +27,6 @@ import com.example.mycrib.viewmodel.ContractorViewModel import com.example.mycrib.models.ContractorSummary import com.example.mycrib.network.ApiResult import com.example.mycrib.repository.LookupsRepository -import com.example.mycrib.cache.SubscriptionCache import com.example.mycrib.ui.subscription.UpgradeFeatureScreen import com.example.mycrib.utils.SubscriptionHelper @@ -42,9 +41,14 @@ fun ContractorsScreen( val deleteState by viewModel.deleteState.collectAsState() val toggleFavoriteState by viewModel.toggleFavoriteState.collectAsState() val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState() - val shouldShowUpgradePrompt = SubscriptionHelper.shouldShowUpgradePromptForContractors().allowed + + // Check if screen should be blocked (limit=0) + val isBlocked = SubscriptionHelper.isContractorsBlocked() + // Get current count for checking when adding + val currentCount = (contractorsState as? ApiResult.Success)?.data?.size ?: 0 var showAddDialog by remember { mutableStateOf(false) } + var showUpgradeDialog by remember { mutableStateOf(false) } var selectedFilter by remember { mutableStateOf(null) } var showFavoritesOnly by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } @@ -164,104 +168,119 @@ fun ContractorsScreen( ) }, floatingActionButton = { - Box(modifier = Modifier.padding(bottom = 80.dp)) { - FloatingActionButton( - onClick = { showAddDialog = true }, - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) { - Icon(Icons.Default.Add, "Add contractor") + // Don't show FAB if screen is blocked (limit=0) + if (!isBlocked.allowed) { + Box(modifier = Modifier.padding(bottom = 80.dp)) { + FloatingActionButton( + onClick = { + // Check if user can add based on current count + val canAdd = SubscriptionHelper.canAddContractor(currentCount) + if (canAdd.allowed) { + showAddDialog = true + } else { + showUpgradeDialog = true + } + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon(Icons.Default.Add, "Add contractor") + } } } } ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .background(MaterialTheme.colorScheme.background) - ) { - // Search bar - OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, + // Show upgrade prompt for the entire screen if blocked (limit=0) + if (isBlocked.allowed) { + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - placeholder = { Text("Search contractors...") }, - leadingIcon = { Icon(Icons.Default.Search, "Search") }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { searchQuery = "" }) { - Icon(Icons.Default.Close, "Clear search") - } - } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline + .fillMaxSize() + .padding(padding) + ) { + UpgradeFeatureScreen( + triggerKey = isBlocked.triggerKey ?: "view_contractors", + icon = Icons.Default.People, + onNavigateBack = onNavigateBack ) - ) - - // Active filters display - if (selectedFilter != null || showFavoritesOnly) { - Row( + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(MaterialTheme.colorScheme.background) + ) { + // Search bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (showFavoritesOnly) { - FilterChip( - selected = true, - onClick = { showFavoritesOnly = false }, - label = { Text("Favorites") }, - leadingIcon = { Icon(Icons.Default.Star, null, modifier = Modifier.size(16.dp)) } - ) - } - if (selectedFilter != null) { - FilterChip( - selected = true, - onClick = { selectedFilter = null }, - label = { Text(selectedFilter!!) }, - trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(16.dp)) } - ) - } - } - } - - ApiResultHandler( - state = contractorsState, - onRetry = { - viewModel.loadContractors( - specialty = selectedFilter, - isFavorite = if (showFavoritesOnly) true else null, - search = searchQuery.takeIf { it.isNotBlank() } + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("Search contractors...") }, + leadingIcon = { Icon(Icons.Default.Search, "Search") }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Default.Close, "Clear search") + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline ) - }, - errorTitle = "Failed to Load Contractors", - loadingContent = { - if (!isRefreshing) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + ) + + // Active filters display + if (selectedFilter != null || showFavoritesOnly) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (showFavoritesOnly) { + FilterChip( + selected = true, + onClick = { showFavoritesOnly = false }, + label = { Text("Favorites") }, + leadingIcon = { Icon(Icons.Default.Star, null, modifier = Modifier.size(16.dp)) } + ) + } + if (selectedFilter != null) { + FilterChip( + selected = true, + onClick = { selectedFilter = null }, + label = { Text(selectedFilter!!) }, + trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(16.dp)) } + ) + } } } - ) { state -> - val contractors = state - if (contractors.isEmpty()) { - if (shouldShowUpgradePrompt) { - // Free tier users see upgrade prompt - UpgradeFeatureScreen( - triggerKey = "view_contractors", - icon = Icons.Default.People, - onNavigateBack = onNavigateBack + ApiResultHandler( + state = contractorsState, + onRetry = { + viewModel.loadContractors( + specialty = selectedFilter, + isFavorite = if (showFavoritesOnly) true else null, + search = searchQuery.takeIf { it.isNotBlank() } ) - } else { - // Pro users see empty state + }, + errorTitle = "Failed to Load Contractors", + loadingContent = { + if (!isRefreshing) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } + ) { contractors -> + if (contractors.isEmpty()) { + // Empty state Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -292,31 +311,31 @@ fun ContractorsScreen( } } } - } - } else { - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = { - isRefreshing = true - viewModel.loadContractors( - specialty = selectedFilter, - isFavorite = if (showFavoritesOnly) true else null, - search = searchQuery.takeIf { it.isNotBlank() } - ) - }, - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(contractors, key = { it.id }) { contractor -> - ContractorCard( - contractor = contractor, - onToggleFavorite = { viewModel.toggleFavorite(it) }, - onClick = { onNavigateToContractorDetail(it) } + } else { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + isRefreshing = true + viewModel.loadContractors( + specialty = selectedFilter, + isFavorite = if (showFavoritesOnly) true else null, + search = searchQuery.takeIf { it.isNotBlank() } ) + }, + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(contractors, key = { it.id }) { contractor -> + ContractorCard( + contractor = contractor, + onToggleFavorite = { viewModel.toggleFavorite(it) }, + onClick = { onNavigateToContractorDetail(it) } + ) + } } } } @@ -334,6 +353,22 @@ fun ContractorsScreen( } ) } + + // Show upgrade dialog when user hits limit + if (showUpgradeDialog) { + AlertDialog( + onDismissRequest = { showUpgradeDialog = false }, + title = { Text("Upgrade Required") }, + text = { + Text("You've reached the maximum number of contractors for your current plan. Upgrade to Pro for unlimited contractors.") + }, + confirmButton = { + TextButton(onClick = { showUpgradeDialog = false }) { + Text("OK") + } + } + ) + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt index b93b05b..23f77be 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt @@ -5,12 +5,15 @@ 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.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.example.mycrib.ui.components.documents.DocumentsTabContent +import com.example.mycrib.ui.subscription.UpgradeFeatureScreen +import com.example.mycrib.utils.SubscriptionHelper import com.example.mycrib.viewmodel.DocumentViewModel import com.example.mycrib.models.* @@ -30,10 +33,16 @@ fun DocumentsScreen( var selectedTab by remember { mutableStateOf(DocumentTab.WARRANTIES) } val documentsState by documentViewModel.documentsState.collectAsState() + // Check if screen should be blocked (limit=0) + val isBlocked = SubscriptionHelper.isDocumentsBlocked() + // Get current count for checking when adding + val currentCount = (documentsState as? com.example.mycrib.network.ApiResult.Success)?.data?.size ?: 0 + var selectedCategory by remember { mutableStateOf(null) } var selectedDocType by remember { mutableStateOf(null) } var showActiveOnly by remember { mutableStateOf(true) } var showFiltersMenu by remember { mutableStateOf(false) } + var showUpgradeDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { // Load warranties by default (documentType="warranty") @@ -157,16 +166,25 @@ fun DocumentsScreen( } }, floatingActionButton = { - Box(modifier = Modifier.padding(bottom = 80.dp)) { - FloatingActionButton( - onClick = { - val documentType = if (selectedTab == DocumentTab.WARRANTIES) "warranty" else "other" - // Pass residenceId even if null - AddDocumentScreen will handle it - onNavigateToAddDocument(residenceId ?: -1, documentType) - }, - containerColor = MaterialTheme.colorScheme.primary - ) { - Icon(Icons.Default.Add, "Add") + // Don't show FAB if screen is blocked (limit=0) + if (!isBlocked.allowed) { + Box(modifier = Modifier.padding(bottom = 80.dp)) { + FloatingActionButton( + onClick = { + // Check if user can add based on current count + val canAdd = SubscriptionHelper.canAddDocument(currentCount) + if (canAdd.allowed) { + val documentType = if (selectedTab == DocumentTab.WARRANTIES) "warranty" else "other" + // Pass residenceId even if null - AddDocumentScreen will handle it + onNavigateToAddDocument(residenceId ?: -1, documentType) + } else { + showUpgradeDialog = true + } + }, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon(Icons.Default.Add, "Add") + } } } } @@ -176,38 +194,64 @@ fun DocumentsScreen( .fillMaxSize() .padding(padding) ) { - when (selectedTab) { - DocumentTab.WARRANTIES -> { - DocumentsTabContent( - state = documentsState, - isWarrantyTab = true, - onDocumentClick = onNavigateToDocumentDetail, - onRetry = { - documentViewModel.loadDocuments( - residenceId = residenceId, - documentType = "warranty", - category = selectedCategory, - isActive = if (showActiveOnly) true else null - ) - }, - onNavigateBack = onNavigateBack - ) - } - DocumentTab.DOCUMENTS -> { - DocumentsTabContent( - state = documentsState, - isWarrantyTab = false, - onDocumentClick = onNavigateToDocumentDetail, - onRetry = { - documentViewModel.loadDocuments( - residenceId = residenceId, - documentType = selectedDocType - ) - }, - onNavigateBack = onNavigateBack - ) + if (isBlocked.allowed) { + // Screen is blocked (limit=0) - show upgrade prompt + UpgradeFeatureScreen( + triggerKey = isBlocked.triggerKey ?: "view_documents", + icon = Icons.Default.Description, + onNavigateBack = onNavigateBack + ) + } else { + // Pro users see normal content + when (selectedTab) { + DocumentTab.WARRANTIES -> { + DocumentsTabContent( + state = documentsState, + isWarrantyTab = true, + onDocumentClick = onNavigateToDocumentDetail, + onRetry = { + documentViewModel.loadDocuments( + residenceId = residenceId, + documentType = "warranty", + category = selectedCategory, + isActive = if (showActiveOnly) true else null + ) + }, + onNavigateBack = onNavigateBack + ) + } + DocumentTab.DOCUMENTS -> { + DocumentsTabContent( + state = documentsState, + isWarrantyTab = false, + onDocumentClick = onNavigateToDocumentDetail, + onRetry = { + documentViewModel.loadDocuments( + residenceId = residenceId, + documentType = selectedDocType + ) + }, + onNavigateBack = onNavigateBack + ) + } } } } } + + // Show upgrade dialog when user hits limit + if (showUpgradeDialog) { + AlertDialog( + onDismissRequest = { showUpgradeDialog = false }, + title = { Text("Upgrade Required") }, + text = { + Text("You've reached the maximum number of documents for your current plan. Upgrade to Pro for unlimited documents.") + }, + confirmButton = { + TextButton(onClick = { showUpgradeDialog = false }) { + Text("OK") + } + } + ) + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index a2e3d92..8594b02 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -69,29 +69,15 @@ fun ResidenceDetailScreen( var showUpgradePrompt by remember { mutableStateOf(false) } var upgradeTriggerKey by remember { mutableStateOf(null) } - // Helper function to check LIVE task count against limits + // Check if tasks are blocked (limit=0) - this hides the FAB + val isTasksBlocked = SubscriptionHelper.isTasksBlocked() + // Get current count for checking when adding + val currentTaskCount = (tasksState as? ApiResult.Success)?.data?.columns?.sumOf { it.tasks.size } ?: 0 + + // Helper function to check if user can add a task fun canAddTask(): Pair { - val subscription = SubscriptionCache.currentSubscription.value ?: return Pair(true, null) - - // If limitations are disabled globally, allow everything - if (!subscription.limitationsEnabled) { - return Pair(true, null) - } - - // Pro tier has no limits - if (SubscriptionHelper.currentTier == "pro") { - return Pair(true, null) - } - - // Check LIVE count from current tasks state - val currentCount = (tasksState as? ApiResult.Success)?.data?.columns?.sumOf { it.tasks.size } ?: 0 - val limit = subscription.limits[SubscriptionHelper.currentTier]?.tasks ?: 10 - - return if (currentCount >= limit) { - Pair(false, "add_11th_task") - } else { - Pair(true, null) - } + val check = SubscriptionHelper.canAddTask(currentTaskCount) + return Pair(check.allowed, check.triggerKey) } LaunchedEffect(residenceId) { @@ -443,20 +429,23 @@ fun ResidenceDetailScreen( ) }, floatingActionButton = { - FloatingActionButton( - onClick = { - val (allowed, triggerKey) = canAddTask() - if (allowed) { - showNewTaskDialog = true - } else { - upgradeTriggerKey = triggerKey - showUpgradePrompt = true - } - }, - containerColor = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(16.dp) - ) { - Icon(Icons.Default.Add, contentDescription = "Add Task") + // Don't show FAB if tasks are blocked (limit=0) + if (!isTasksBlocked.allowed) { + FloatingActionButton( + onClick = { + val (allowed, triggerKey) = canAddTask() + if (allowed) { + showNewTaskDialog = true + } else { + upgradeTriggerKey = triggerKey + showUpgradePrompt = true + } + }, + containerColor = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(16.dp) + ) { + Icon(Icons.Default.Add, contentDescription = "Add Task") + } } } ) { paddingValues -> diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index b4c4972..726374b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -46,29 +46,15 @@ fun ResidencesScreen( var showUpgradePrompt by remember { mutableStateOf(false) } var upgradeTriggerKey by remember { mutableStateOf(null) } - // Helper function to check LIVE property count against limits + // Check if screen is blocked (limit=0) - this hides the FAB + val isBlocked = SubscriptionHelper.isResidencesBlocked() + // Get current count for checking when adding + val currentCount = (myResidencesState as? ApiResult.Success)?.data?.residences?.size ?: 0 + + // Helper function to check if user can add a property fun canAddProperty(): Pair { - val subscription = SubscriptionCache.currentSubscription.value ?: return Pair(true, null) - - // If limitations are disabled globally, allow everything - if (!subscription.limitationsEnabled) { - return Pair(true, null) - } - - // Pro tier has no limits - if (SubscriptionHelper.currentTier == "pro") { - return Pair(true, null) - } - - // Check LIVE count from current state - val currentCount = (myResidencesState as? ApiResult.Success)?.data?.residences?.size ?: 0 - val limit = subscription.limits[SubscriptionHelper.currentTier]?.properties ?: 1 - - return if (currentCount >= limit) { - Pair(false, "add_second_property") - } else { - Pair(true, null) - } + val check = SubscriptionHelper.canAddProperty(currentCount) + return Pair(check.allowed, check.triggerKey) } LaunchedEffect(Unit) { @@ -126,16 +112,19 @@ fun ResidencesScreen( ) }, actions = { - IconButton(onClick = { - val (allowed, triggerKey) = canAddProperty() - if (allowed) { - showJoinDialog = true - } else { - upgradeTriggerKey = triggerKey - showUpgradePrompt = true + // Only show Join button if not blocked (limit>0) + if (!isBlocked.allowed) { + IconButton(onClick = { + val (allowed, triggerKey) = canAddProperty() + if (allowed) { + showJoinDialog = true + } else { + upgradeTriggerKey = triggerKey + showUpgradePrompt = true + } + }) { + Icon(Icons.Default.GroupAdd, contentDescription = "Join with Code") } - }) { - Icon(Icons.Default.GroupAdd, contentDescription = "Join with Code") } IconButton(onClick = onNavigateToProfile) { Icon(Icons.Default.AccountCircle, contentDescription = "Profile") @@ -150,11 +139,11 @@ fun ResidencesScreen( ) }, floatingActionButton = { - // Only show FAB when there are properties + // Only show FAB when there are properties and NOT blocked (limit>0) val hasResidences = myResidencesState is ApiResult.Success && (myResidencesState as ApiResult.Success).data.residences.isNotEmpty() - if (hasResidences) { + if (hasResidences && !isBlocked.allowed) { Box(modifier = Modifier.padding(bottom = 80.dp)) { FloatingActionButton( onClick = { @@ -218,59 +207,86 @@ fun ResidencesScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = { - val (allowed, triggerKey) = canAddProperty() - if (allowed) { - onAddResidence() - } else { - upgradeTriggerKey = triggerKey - showUpgradePrompt = true - } - }, - modifier = Modifier - .fillMaxWidth(0.7f) - .height(56.dp), - shape = RoundedCornerShape(12.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + // Only show Add Property button if not blocked (limit>0) + if (!isBlocked.allowed) { + Button( + onClick = { + val (allowed, triggerKey) = canAddProperty() + if (allowed) { + onAddResidence() + } else { + upgradeTriggerKey = triggerKey + showUpgradePrompt = true + } + }, + modifier = Modifier + .fillMaxWidth(0.7f) + .height(56.dp), + shape = RoundedCornerShape(12.dp) ) { - Icon(Icons.Default.Add, contentDescription = null) - Text( - "Add Property", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Add, contentDescription = null) + Text( + "Add Property", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } } - } - Spacer(modifier = Modifier.height(8.dp)) - OutlinedButton( - onClick = { - val (allowed, triggerKey) = canAddProperty() - if (allowed) { - showJoinDialog = true - } else { - upgradeTriggerKey = triggerKey - showUpgradePrompt = true - } - }, - modifier = Modifier - .fillMaxWidth(0.7f) - .height(56.dp), - shape = RoundedCornerShape(12.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { + val (allowed, triggerKey) = canAddProperty() + if (allowed) { + showJoinDialog = true + } else { + upgradeTriggerKey = triggerKey + showUpgradePrompt = true + } + }, + modifier = Modifier + .fillMaxWidth(0.7f) + .height(56.dp), + shape = RoundedCornerShape(12.dp) ) { - Icon(Icons.Default.GroupAdd, contentDescription = null) - Text( - "Join with Code", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.GroupAdd, contentDescription = null) + Text( + "Join with Code", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + } else { + // Show upgrade prompt when limit=0 + Button( + onClick = { + upgradeTriggerKey = isBlocked.triggerKey + showUpgradePrompt = true + }, + modifier = Modifier + .fillMaxWidth(0.7f) + .height(56.dp), + shape = RoundedCornerShape(12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Star, contentDescription = null) + Text( + "Upgrade to Add", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt index f9ac137..d3e602f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt @@ -1,6 +1,8 @@ package com.example.mycrib.ui.subscription import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -11,9 +13,14 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.example.mycrib.cache.SubscriptionCache import com.example.mycrib.ui.theme.AppRadius import com.example.mycrib.ui.theme.AppSpacing +/** + * Full inline paywall screen for upgrade prompts. + * Shows feature benefits, subscription products with pricing, and action buttons. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun UpgradeFeatureScreen( @@ -21,11 +28,14 @@ fun UpgradeFeatureScreen( icon: ImageVector, onNavigateBack: () -> Unit ) { - var showUpgradeDialog by remember { mutableStateOf(false) } + var showFeatureComparison by remember { mutableStateOf(false) } + var isProcessing by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var showSuccessAlert by remember { mutableStateOf(false) } // Look up trigger data from cache val triggerData by remember { derivedStateOf { - com.example.mycrib.cache.SubscriptionCache.upgradeTriggers.value[triggerKey] + SubscriptionCache.upgradeTriggers.value[triggerKey] } } // Fallback values if trigger not found @@ -48,84 +58,305 @@ fun UpgradeFeatureScreen( ) } ) { paddingValues -> - Box( + Column( modifier = Modifier .fillMaxSize() - .padding(paddingValues), - contentAlignment = Alignment.Center + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( + Spacer(Modifier.height(AppSpacing.xl)) + + // Feature Icon (star gradient like iOS) + Icon( + imageVector = Icons.Default.Stars, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + + Spacer(Modifier.height(AppSpacing.lg)) + + // Title + Text( + title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = AppSpacing.lg) + ) + + Spacer(Modifier.height(AppSpacing.md)) + + // Description + Text( + message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = AppSpacing.lg) + ) + + Spacer(Modifier.height(AppSpacing.xl)) + + // Pro Features Preview Card + Surface( modifier = Modifier .fillMaxWidth() - .padding(AppSpacing.xl), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(AppSpacing.lg) + .padding(horizontal = AppSpacing.lg), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant ) { - // Feature Icon - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(80.dp), - tint = MaterialTheme.colorScheme.primary - ) - - // Title - Text( - title, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - // Description - Text( - message, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - // Upgrade Badge - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.tertiaryContainer + Column( + modifier = Modifier.padding(AppSpacing.lg), + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) ) { - Text( - "This feature is available with Pro", - modifier = Modifier.padding( - horizontal = AppSpacing.md, - vertical = AppSpacing.sm - ), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.tertiary - ) + FeatureRow(Icons.Default.Home, "Unlimited properties") + FeatureRow(Icons.Default.CheckCircle, "Unlimited tasks") + FeatureRow(Icons.Default.People, "Contractor management") + FeatureRow(Icons.Default.Description, "Document & warranty storage") } + } - Spacer(Modifier.height(AppSpacing.lg)) + Spacer(Modifier.height(AppSpacing.xl)) - // Upgrade Button - Button( - onClick = { showUpgradeDialog = true }, + // Subscription Products Section + // Note: On Android, BillingManager provides real pricing + // This is a placeholder showing static options + SubscriptionProductsSection( + isProcessing = isProcessing, + onProductSelected = { productId -> + // Trigger purchase flow + // On Android, this connects to BillingManager + isProcessing = true + errorMessage = null + // Purchase will be handled by platform-specific code + }, + onRetryLoad = { + // Retry loading products + } + ) + + // Error Message + errorMessage?.let { error -> + Spacer(Modifier.height(AppSpacing.md)) + Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = AppSpacing.lg), - shape = MaterialTheme.shapes.medium + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) ) { - Text(buttonText, fontWeight = FontWeight.Bold) + Row( + modifier = Modifier.padding(AppSpacing.md), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } } } + + Spacer(Modifier.height(AppSpacing.lg)) + + // Compare Plans + TextButton(onClick = { showFeatureComparison = true }) { + Text("Compare Free vs Pro") + } + + // Restore Purchases + TextButton(onClick = { + // Trigger restore purchases + isProcessing = true + errorMessage = null + }) { + Text( + "Restore Purchases", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(Modifier.height(AppSpacing.xl * 2)) } - if (showUpgradeDialog) { - UpgradePromptDialog( - triggerKey = triggerKey, - onDismiss = { showUpgradeDialog = false }, + if (showFeatureComparison) { + FeatureComparisonDialog( + onDismiss = { showFeatureComparison = false }, onUpgrade = { - // TODO: Trigger Google Play Billing - showUpgradeDialog = false + // Trigger upgrade + showFeatureComparison = false + } + ) + } + + if (showSuccessAlert) { + AlertDialog( + onDismissRequest = { showSuccessAlert = false }, + title = { Text("Subscription Active") }, + text = { Text("You now have full access to all Pro features!") }, + confirmButton = { + TextButton(onClick = { + showSuccessAlert = false + onNavigateBack() + }) { + Text("Done") + } } ) } } } + +@Composable +private fun FeatureRow(icon: ImageVector, text: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun SubscriptionProductsSection( + isProcessing: Boolean, + onProductSelected: (String) -> Unit, + onRetryLoad: () -> Unit +) { + // Static subscription options (pricing will be updated by platform billing) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.lg), + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + // Monthly Option + SubscriptionProductCard( + productId = "com.example.mycrib.pro.monthly", + name = "MyCrib Pro Monthly", + price = "$4.99/month", + description = "Billed monthly", + savingsBadge = null, + isSelected = false, + isProcessing = isProcessing, + onSelect = { onProductSelected("com.example.mycrib.pro.monthly") } + ) + + // Annual Option + SubscriptionProductCard( + productId = "com.example.mycrib.pro.annual", + name = "MyCrib Pro Annual", + price = "$39.99/year", + description = "Billed annually", + savingsBadge = "Save 33%", + isSelected = false, + isProcessing = isProcessing, + onSelect = { onProductSelected("com.example.mycrib.pro.annual") } + ) + } +} + +@Composable +private fun SubscriptionProductCard( + productId: String, + name: String, + price: String, + description: String, + savingsBadge: String?, + isSelected: Boolean, + isProcessing: Boolean, + onSelect: () -> Unit +) { + Card( + onClick = onSelect, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surface + ), + border = if (isSelected) + androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + else + null + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpacing.lg), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + savingsBadge?.let { badge -> + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.tertiaryContainer + ) { + Text( + badge, + modifier = Modifier.padding( + horizontal = AppSpacing.sm, + vertical = 2.dp + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Bold + ) + } + } + } + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (isProcessing) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Text( + price, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt index 037fb92..472c8c0 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/utils/SubscriptionHelper.kt @@ -2,30 +2,87 @@ package com.example.mycrib.utils import com.example.mycrib.cache.SubscriptionCache +/** + * Helper for checking subscription limits and determining when to show upgrade prompts. + * + * RULES: + * 1. Backend limitations OFF: Never show upgrade view, allow everything + * 2. Backend limitations ON + limit=0: Show upgrade view, block access entirely (no add button) + * 3. Backend limitations ON + limit>0: Allow access with add button, show upgrade when limit reached + * + * These rules apply to: residence, task, contractors, documents + */ object SubscriptionHelper { + /** + * Result of a usage/access check + * @param allowed Whether the action is allowed + * @param triggerKey The upgrade trigger key to use if not allowed (null if allowed) + */ data class UsageCheck(val allowed: Boolean, val triggerKey: String?) // NOTE: For Android, currentTier should be set from Google Play Billing // For iOS, tier is managed by SubscriptionCacheWrapper from StoreKit var currentTier: String = "free" + // ===== PROPERTY (RESIDENCE) ===== + + /** + * Check if the user should see an upgrade view instead of the residences screen. + * Returns true (blocked) only when limitations are ON and limit=0. + */ + fun isResidencesBlocked(): UsageCheck { + val subscription = SubscriptionCache.currentSubscription.value + ?: return UsageCheck(false, null) // Allow access while loading + + if (!subscription.limitationsEnabled) { + return UsageCheck(false, null) // Limitations disabled, never block + } + + if (currentTier == "pro") { + return UsageCheck(false, null) // Pro users never blocked + } + + val limit = subscription.limits[currentTier]?.properties + + // If limit is 0, block access entirely + if (limit == 0) { + return UsageCheck(true, "add_second_property") + } + + return UsageCheck(false, null) // limit > 0 or unlimited, allow access + } + + /** + * Check if user can add a property (when trying to add, not for blocking the screen). + * Used when limit > 0 and user has reached the limit. + */ fun canAddProperty(currentCount: Int = 0): UsageCheck { val subscription = SubscriptionCache.currentSubscription.value ?: return UsageCheck(true, null) // Allow if no subscription data - // If limitations are disabled globally, allow everything if (!subscription.limitationsEnabled) { - return UsageCheck(true, null) + return UsageCheck(true, null) // Limitations disabled, allow everything } - // Pro tier gets unlimited access if (currentTier == "pro") { + return UsageCheck(true, null) // Pro tier gets unlimited access + } + + // Get limit for current tier (null = unlimited) + val limit = subscription.limits[currentTier]?.properties + + // null means unlimited + if (limit == null) { return UsageCheck(true, null) } - // Get limit for current tier - val limit = subscription.limits[currentTier]?.properties ?: 1 + // If limit is 0, they shouldn't even be here (screen should be blocked) + // But if they somehow are, block the add + if (limit == 0) { + return UsageCheck(false, "add_second_property") + } + // limit > 0: check if they've reached it if (currentCount >= limit) { return UsageCheck(false, "add_second_property") } @@ -33,22 +90,57 @@ object SubscriptionHelper { return UsageCheck(true, null) } + // ===== TASKS ===== + + /** + * Check if the user should see an upgrade view instead of the tasks screen. + * Returns true (blocked) only when limitations are ON and limit=0. + */ + fun isTasksBlocked(): UsageCheck { + val subscription = SubscriptionCache.currentSubscription.value + ?: return UsageCheck(false, null) // Allow access while loading + + if (!subscription.limitationsEnabled) { + return UsageCheck(false, null) + } + + if (currentTier == "pro") { + return UsageCheck(false, null) + } + + val limit = subscription.limits[currentTier]?.tasks + + if (limit == 0) { + return UsageCheck(true, "add_11th_task") + } + + return UsageCheck(false, null) + } + + /** + * Check if user can add a task (when trying to add). + */ fun canAddTask(currentCount: Int = 0): UsageCheck { val subscription = SubscriptionCache.currentSubscription.value ?: return UsageCheck(true, null) - // If limitations are disabled globally, allow everything if (!subscription.limitationsEnabled) { return UsageCheck(true, null) } - // Pro tier gets unlimited access if (currentTier == "pro") { return UsageCheck(true, null) } - // Get limit for current tier - val limit = subscription.limits[currentTier]?.tasks ?: 10 + val limit = subscription.limits[currentTier]?.tasks + + if (limit == null) { + return UsageCheck(true, null) // Unlimited + } + + if (limit == 0) { + return UsageCheck(false, "add_11th_task") + } if (currentCount >= limit) { return UsageCheck(false, "add_11th_task") @@ -57,39 +149,129 @@ object SubscriptionHelper { return UsageCheck(true, null) } - fun shouldShowUpgradePromptForContractors(): UsageCheck { - val subscription = SubscriptionCache.currentSubscription.value - ?: return UsageCheck(false, null) + // ===== CONTRACTORS ===== + + /** + * Check if the user should see an upgrade view instead of the contractors screen. + * Returns true (blocked) only when limitations are ON and limit=0. + */ + fun isContractorsBlocked(): UsageCheck { + val subscription = SubscriptionCache.currentSubscription.value + ?: return UsageCheck(false, null) // Allow access while loading - // If limitations are disabled globally, don't show prompt if (!subscription.limitationsEnabled) { return UsageCheck(false, null) } - // Pro users don't see the prompt if (currentTier == "pro") { return UsageCheck(false, null) } - // Free users see the upgrade prompt - return UsageCheck(true, "view_contractors") + val limit = subscription.limits[currentTier]?.contractors + + if (limit == 0) { + return UsageCheck(true, "view_contractors") + } + + return UsageCheck(false, null) } - fun shouldShowUpgradePromptForDocuments(): UsageCheck { + /** + * Check if user can add a contractor (when trying to add). + */ + fun canAddContractor(currentCount: Int = 0): UsageCheck { val subscription = SubscriptionCache.currentSubscription.value - ?: return UsageCheck(false, null) + ?: return UsageCheck(true, null) + + if (!subscription.limitationsEnabled) { + return UsageCheck(true, null) + } + + if (currentTier == "pro") { + return UsageCheck(true, null) + } + + val limit = subscription.limits[currentTier]?.contractors + + if (limit == null) { + return UsageCheck(true, null) + } + + if (limit == 0) { + return UsageCheck(false, "view_contractors") + } + + if (currentCount >= limit) { + return UsageCheck(false, "view_contractors") + } + + return UsageCheck(true, null) + } + + // ===== DOCUMENTS ===== + + /** + * Check if the user should see an upgrade view instead of the documents screen. + * Returns true (blocked) only when limitations are ON and limit=0. + */ + fun isDocumentsBlocked(): UsageCheck { + val subscription = SubscriptionCache.currentSubscription.value + ?: return UsageCheck(false, null) // Allow access while loading - // If limitations are disabled globally, don't show prompt if (!subscription.limitationsEnabled) { return UsageCheck(false, null) } - // Pro users don't see the prompt if (currentTier == "pro") { return UsageCheck(false, null) } - // Free users see the upgrade prompt - return UsageCheck(true, "view_documents") + val limit = subscription.limits[currentTier]?.documents + + if (limit == 0) { + return UsageCheck(true, "view_documents") + } + + return UsageCheck(false, null) } + + /** + * Check if user can add a document (when trying to add). + */ + fun canAddDocument(currentCount: Int = 0): UsageCheck { + val subscription = SubscriptionCache.currentSubscription.value + ?: return UsageCheck(true, null) + + if (!subscription.limitationsEnabled) { + return UsageCheck(true, null) + } + + if (currentTier == "pro") { + return UsageCheck(true, null) + } + + val limit = subscription.limits[currentTier]?.documents + + if (limit == null) { + return UsageCheck(true, null) + } + + if (limit == 0) { + return UsageCheck(false, "view_documents") + } + + if (currentCount >= limit) { + return UsageCheck(false, "view_documents") + } + + return UsageCheck(true, null) + } + + // ===== DEPRECATED - Keep for backwards compatibility ===== + + @Deprecated("Use isContractorsBlocked() instead", ReplaceWith("isContractorsBlocked()")) + fun shouldShowUpgradePromptForContractors(): UsageCheck = isContractorsBlocked() + + @Deprecated("Use isDocumentsBlocked() instead", ReplaceWith("isDocumentsBlocked()")) + fun shouldShowUpgradePromptForDocuments(): UsageCheck = isDocumentsBlocked() } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt index 72bbe64..2dfc27d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt @@ -33,11 +33,11 @@ class ResidenceViewModel : ViewModel() { private val _myResidencesState = MutableStateFlow>(ApiResult.Idle) val myResidencesState: StateFlow> = _myResidencesState - private val _cancelTaskState = MutableStateFlow>(ApiResult.Idle) - val cancelTaskState: StateFlow> = _cancelTaskState + private val _cancelTaskState = MutableStateFlow>(ApiResult.Idle) + val cancelTaskState: StateFlow> = _cancelTaskState - private val _uncancelTaskState = MutableStateFlow>(ApiResult.Idle) - val uncancelTaskState: StateFlow> = _uncancelTaskState + private val _uncancelTaskState = MutableStateFlow>(ApiResult.Idle) + val uncancelTaskState: StateFlow> = _uncancelTaskState private val _updateTaskState = MutableStateFlow>(ApiResult.Idle) val updateTaskState: StateFlow> = _updateTaskState diff --git a/docs/ANDROID_SUBSCRIPTION_PLAN.md b/docs/ANDROID_SUBSCRIPTION_PLAN.md new file mode 100644 index 0000000..64d44c7 --- /dev/null +++ b/docs/ANDROID_SUBSCRIPTION_PLAN.md @@ -0,0 +1,276 @@ +# Android Subscription & Upgrade UI Parity Plan + +## Goal +Bring Android subscription/upgrade functionality and UX to match iOS implementation: +1. Show full inline paywall (not teaser + dialog) +2. Implement Google Play Billing integration +3. Disable FAB when upgrade screen is showing + +## Current State + +### iOS (Reference) +- `UpgradeFeatureView` shows full inline paywall with: + - Promo content card with feature bullets + - Subscription product buttons with real pricing + - Purchase flow via StoreKit 2 + - "Compare Free vs Pro" and "Restore Purchases" links +- Add button disabled/grayed when upgrade showing +- `StoreKitManager` fully implemented + +### Android (Current) +- `UpgradeFeatureScreen` shows simple teaser → opens `UpgradePromptDialog` +- FAB always enabled +- `BillingManager` is a stub (no real billing) +- No Google Play Billing dependency + +--- + +## Implementation Plan + +### Phase 1: Add Google Play Billing Dependency + +**Files to modify:** +- `gradle/libs.versions.toml` - Add billing library version +- `composeApp/build.gradle.kts` - Add dependency to androidMain + +```toml +# libs.versions.toml +billing = "7.1.1" + +[libraries] +google-billing = { module = "com.android.billingclient:billing-ktx", version.ref = "billing" } +``` + +```kotlin +# build.gradle.kts - androidMain.dependencies +implementation(libs.google.billing) +``` + +--- + +### Phase 2: Implement BillingManager + +**File:** `composeApp/src/androidMain/kotlin/com/example/mycrib/platform/BillingManager.kt` + +Replace stub implementation with full Google Play Billing: + +```kotlin +class BillingManager private constructor(private val context: Context) { + // Product IDs (match Google Play Console) + private val productIDs = listOf( + "com.example.mycrib.pro.monthly", + "com.example.mycrib.pro.annual" + ) + + // BillingClient instance + private var billingClient: BillingClient + + // StateFlows for UI + val products = MutableStateFlow>(emptyList()) + val purchasedProductIDs = MutableStateFlow>(emptySet()) + val isLoading = MutableStateFlow(false) + val purchaseError = MutableStateFlow(null) + + // Key methods to implement: + - startConnection() - Connect to Google Play + - loadProducts() - Query subscription products + - purchase(activity, productDetails) - Launch purchase flow + - restorePurchases() - Query purchase history + - verifyPurchaseWithBackend() - Call SubscriptionApi.verifyAndroidPurchase() + - acknowledgePurchase() - Required by Google + - listenForPurchases() - PurchasesUpdatedListener +``` + +**Key implementation details:** +1. Initialize BillingClient with PurchasesUpdatedListener +2. Handle billing connection state (retry on disconnect) +3. Query products using QueryProductDetailsParams with ProductType.SUBS +4. Launch purchase flow with BillingFlowParams +5. Process purchase results and verify with backend +6. Acknowledge purchases (required or they refund after 3 days) +7. Update SubscriptionCache after successful verification + +--- + +### Phase 3: Update UpgradeFeatureScreen + +**File:** `composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt` + +Transform from teaser+dialog to full inline paywall matching iOS: + +**Current structure:** +- Icon, title, message, badge +- Button opens UpgradePromptDialog + +**New structure:** +```kotlin +@Composable +fun UpgradeFeatureScreen( + triggerKey: String, + icon: ImageVector, + onNavigateBack: () -> Unit, + billingManager: BillingManager? = null // Android-only, null on other platforms +) { + // ScrollView with: + // 1. Star icon (accent gradient) + // 2. Title + message from triggerData + // 3. PromoContentCard - feature bullets from triggerData.promoHtml + // 4. SubscriptionProductButtons - show real products with pricing + // 5. "Compare Free vs Pro" button + // 6. "Restore Purchases" button + // 7. Error display if any +} +``` + +**New components to add:** +- `PromoContentCard` - Parse and display promo HTML as composable +- `SubscriptionProductButton` - Display product with name, price, optional savings badge + +--- + +### Phase 4: Create Android-Specific Product Display + +**File:** `composeApp/src/androidMain/kotlin/com/example/mycrib/ui/subscription/SubscriptionProductButton.kt` + +```kotlin +@Composable +fun SubscriptionProductButton( + productDetails: ProductDetails, + isSelected: Boolean, + isProcessing: Boolean, + onSelect: () -> Unit +) { + // Display: + // - Product name (e.g., "MyCrib Pro Monthly") + // - Price from subscriptionOfferDetails + // - "Save X%" badge for annual + // - Loading indicator when processing +} +``` + +**Helper function for savings calculation:** +```kotlin +fun calculateAnnualSavings(monthly: ProductDetails, annual: ProductDetails): Int? +``` + +--- + +### Phase 5: Disable FAB When Upgrade Showing + +**Files to modify:** +- `composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt` +- `composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/DocumentsScreen.kt` + +**Changes:** + +```kotlin +// In ContractorsScreen +val shouldShowUpgradePrompt = SubscriptionHelper.shouldShowUpgradePromptForContractors().allowed + +// Update FAB +FloatingActionButton( + onClick = { if (!shouldShowUpgradePrompt) showAddDialog = true }, + containerColor = if (shouldShowUpgradePrompt) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + else + MaterialTheme.colorScheme.primary, + contentColor = if (shouldShowUpgradePrompt) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + else + MaterialTheme.colorScheme.onPrimary +) { + Icon(Icons.Default.Add, "Add contractor") +} +// Add .alpha() modifier or enabled state +``` + +Same pattern for DocumentsScreen. + +--- + +### Phase 6: Initialize BillingManager in MainActivity + +**File:** `composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt` + +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Existing initializations... + TokenStorage.initialize(...) + ThemeStorage.initialize(...) + ThemeManager.initialize() + + // Add BillingManager initialization + val billingManager = BillingManager.getInstance(applicationContext) + billingManager.startConnection( + onSuccess = { billingManager.loadProducts() }, + onError = { error -> Log.e("Billing", "Connection failed: $error") } + ) + + setContent { + // Pass billingManager to composables that need it + App(billingManager = billingManager) + } +} +``` + +--- + +### Phase 7: Wire Purchase Flow End-to-End + +**Integration points:** + +1. **UpgradeFeatureScreen** observes BillingManager.products StateFlow +2. User taps product → calls BillingManager.purchase(activity, productDetails) +3. **BillingManager** launches Google Play purchase UI +4. On success → calls SubscriptionApi.verifyAndroidPurchase() +5. Backend verifies with Google → updates user's subscription tier +6. **BillingManager** calls SubscriptionApi.getSubscriptionStatus() +7. Updates **SubscriptionCache** with new status +8. UI recomposes, upgrade screen disappears, FAB becomes enabled + +--- + +## Files Summary + +| File | Action | +|------|--------| +| `gradle/libs.versions.toml` | Add billing version | +| `composeApp/build.gradle.kts` | Add billing dependency | +| `BillingManager.kt` | Full rewrite with real billing | +| `UpgradeFeatureScreen.kt` | Transform to inline paywall | +| `ContractorsScreen.kt` | Disable FAB when upgrade showing | +| `DocumentsScreen.kt` | Disable FAB when upgrade showing | +| `MainActivity.kt` | Initialize BillingManager | + +--- + +## Reference Files (iOS Implementation) + +These files show the iOS implementation to mirror: +- `iosApp/iosApp/Subscription/StoreKitManager.swift` - Full billing manager +- `iosApp/iosApp/Subscription/UpgradeFeatureView.swift` - Inline paywall UI +- `iosApp/iosApp/Subscription/UpgradePromptView.swift` - PromoContentView, SubscriptionProductButton + +--- + +## Testing Checklist + +- [ ] Products load from Google Play Console +- [ ] Purchase flow launches correctly +- [ ] Purchase verification with backend works +- [ ] SubscriptionCache updates after purchase +- [ ] FAB disabled when upgrade prompt showing +- [ ] FAB enabled after successful purchase +- [ ] Restore purchases works +- [ ] Error states display correctly + +--- + +## Notes + +- Product IDs must match Google Play Console: `com.example.mycrib.pro.monthly`, `com.example.mycrib.pro.annual` +- Backend endpoint `POST /subscription/verify-android/` already exists in SubscriptionApi +- Testing requires Google Play Console setup with test products +- Use Google's test cards for sandbox testing diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8ceb3c3..4c4757c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ kotlinx-datetime = "0.6.0" ktor = "3.3.1" firebase-bom = "34.0.0" google-services = "4.4.3" +billing = "7.1.1" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -51,6 +52,7 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" } +google-billing = { module = "com.android.billingclient:billing-ktx", version.ref = "billing" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/iosApp/iosApp/Subscription/SubscriptionCache.swift b/iosApp/iosApp/Subscription/SubscriptionCache.swift index c7497e6..e2bd797 100644 --- a/iosApp/iosApp/Subscription/SubscriptionCache.swift +++ b/iosApp/iosApp/Subscription/SubscriptionCache.swift @@ -69,10 +69,18 @@ class SubscriptionCacheWrapper: ObservableObject { } private init() { - // Initialize with current values from Kotlin cache - Task { - await observeSubscriptionStatus() - await observeUpgradeTriggers() + // Start observation of Kotlin cache + Task { @MainActor in + // Initial sync + self.observeSubscriptionStatusSync() + self.observeUpgradeTriggersSync() + + // Poll for updates periodically (workaround for Kotlin StateFlow observation) + while true { + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + self.observeSubscriptionStatusSync() + self.observeUpgradeTriggersSync() + } } } @@ -99,9 +107,24 @@ class SubscriptionCacheWrapper: ObservableObject { } func refreshFromCache() { - Task { - await observeSubscriptionStatus() - await observeUpgradeTriggers() + Task { @MainActor in + observeSubscriptionStatusSync() + observeUpgradeTriggersSync() + } + } + + @MainActor + private func observeSubscriptionStatusSync() { + if let subscription = ComposeApp.SubscriptionCache.shared.currentSubscription.value as? SubscriptionStatus { + self.currentSubscription = subscription + } + } + + @MainActor + private func observeUpgradeTriggersSync() { + let kotlinTriggers = ComposeApp.SubscriptionCache.shared.upgradeTriggers.value as? [String: UpgradeTriggerData] + if let triggers = kotlinTriggers { + self.upgradeTriggers = triggers } } diff --git a/iosApp/iosApp/Subscription/UpgradePromptView.swift b/iosApp/iosApp/Subscription/UpgradePromptView.swift index 3635ac6..58fa4c2 100644 --- a/iosApp/iosApp/Subscription/UpgradePromptView.swift +++ b/iosApp/iosApp/Subscription/UpgradePromptView.swift @@ -2,6 +2,119 @@ import SwiftUI import ComposeApp import StoreKit +// MARK: - Promo Content View + +struct PromoContentView: View { + let content: String + + private var lines: [PromoLine] { + parseContent(content) + } + + var body: some View { + VStack(spacing: 12) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + switch line { + case .emoji(let text): + Text(text) + .font(.system(size: 36)) + + case .title(let text): + Text(text) + .font(.title3.bold()) + .foregroundColor(Color.appPrimary) + .multilineTextAlignment(.center) + + case .body(let text): + Text(text) + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + + case .checkItem(let text): + HStack(alignment: .top, spacing: 8) { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color.appPrimary) + Text(text) + .font(.subheadline) + .foregroundColor(Color.appTextPrimary) + Spacer() + } + + case .italic(let text): + Text(text) + .font(.caption) + .italic() + .foregroundColor(Color.appAccent) + .multilineTextAlignment(.center) + + case .spacer: + Spacer().frame(height: 4) + } + } + } + } + + private enum PromoLine { + case emoji(String) + case title(String) + case body(String) + case checkItem(String) + case italic(String) + case spacer + } + + private func parseContent(_ content: String) -> [PromoLine] { + var result: [PromoLine] = [] + let lines = content.components(separatedBy: "\n") + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.isEmpty { + result.append(.spacer) + } else if trimmed.hasPrefix("✓") { + let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) + result.append(.checkItem(text)) + } else if trimmed.contains("") && trimmed.contains("") { + // Title line with emoji + let cleaned = trimmed + .replacingOccurrences(of: "", with: "") + .replacingOccurrences(of: "", with: "") + + // Check if starts with emoji + if let firstScalar = cleaned.unicodeScalars.first, + firstScalar.properties.isEmoji && !firstScalar.properties.isASCIIHexDigit { + // Split emoji and title + let parts = cleaned.split(separator: " ", maxSplits: 1) + if parts.count == 2 { + result.append(.emoji(String(parts[0]))) + result.append(.title(String(parts[1]))) + } else { + result.append(.title(cleaned)) + } + } else { + result.append(.title(cleaned)) + } + } else if trimmed.hasPrefix("") && trimmed.hasSuffix("") { + let text = trimmed + .replacingOccurrences(of: "", with: "") + .replacingOccurrences(of: "", with: "") + result.append(.italic(text)) + } else if trimmed.first?.unicodeScalars.first?.properties.isEmoji == true && + trimmed.count <= 2 { + // Standalone emoji + result.append(.emoji(trimmed)) + } else { + result.append(.body(trimmed)) + } + } + + return result + } +} + struct UpgradePromptView: View { let triggerKey: String @Binding var isPresented: Bool @@ -42,14 +155,22 @@ struct UpgradePromptView: View { .multilineTextAlignment(.center) .padding(.horizontal) - // Pro Features Preview - VStack(alignment: .leading, spacing: AppSpacing.md) { - FeatureRow(icon: "house.fill", text: "Unlimited properties") - FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks") - FeatureRow(icon: "person.2.fill", text: "Contractor management") - FeatureRow(icon: "doc.fill", text: "Document & warranty storage") + // Pro Features Preview - Dynamic content or fallback + Group { + if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty { + PromoContentView(content: promoContent) + .padding() + } else { + // Fallback to static features if no promo content + VStack(alignment: .leading, spacing: AppSpacing.md) { + FeatureRow(icon: "house.fill", text: "Unlimited properties") + FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks") + FeatureRow(icon: "person.2.fill", text: "Contractor management") + FeatureRow(icon: "doc.fill", text: "Document & warranty storage") + } + .padding() + } } - .padding() .background(Color.appBackgroundSecondary) .cornerRadius(AppRadius.lg) .padding(.horizontal) @@ -153,6 +274,8 @@ struct UpgradePromptView: View { Text("You now have full access to all Pro features!") } .task { + // Refresh subscription cache to get latest upgrade triggers + subscriptionCache.refreshFromCache() await storeKit.loadProducts() } }