Implement Android subscription system with freemium limitations
Major subscription system implementation for Android: BillingManager (Android): - Full Google Play Billing Library integration - Product loading, purchase flow, and acknowledgment - Backend verification via APILayer.verifyAndroidPurchase() - Purchase restoration for returning users - Error handling and connection state management SubscriptionHelper (Shared): - New limit checking methods: isResidencesBlocked(), isTasksBlocked(), isContractorsBlocked(), isDocumentsBlocked() - Add permission checks: canAddProperty(), canAddTask(), canAddContractor(), canAddDocument() - Enforces freemium rules based on backend limitationsEnabled flag Screen Updates: - ContractorsScreen: Show upgrade prompt when contractors limit=0 - DocumentsScreen: Show upgrade prompt when documents limit=0 - ResidencesScreen: Show upgrade prompt when properties limit reached - ResidenceDetailScreen: Show upgrade prompt when tasks limit reached UpgradeFeatureScreen: - Enhanced with feature benefits comparison - Dynamic content from backend upgrade triggers - Platform-specific purchase buttons Additional changes: - DataCache: Added O(1) lookup maps for ID resolution - New minimal models (TaskMinimal, ContractorMinimal, ResidenceMinimal) - TaskApi: Added archive/unarchive endpoints - Added Google Billing Library dependency - iOS SubscriptionCache and UpgradePromptView updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<Boolean> = _isLoading
|
||||
|
||||
private val _products = MutableStateFlow<List<ProductDetails>>(emptyList())
|
||||
val products: StateFlow<List<ProductDetails>> = _products
|
||||
|
||||
private val _purchasedProductIDs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val purchasedProductIDs: StateFlow<Set<String>> = _purchasedProductIDs
|
||||
|
||||
private val _purchaseError = MutableStateFlow<String?>(null)
|
||||
val purchaseError: StateFlow<String?> = _purchaseError
|
||||
|
||||
private val _connectionState = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _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<ProductDetails> {
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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<String?>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user