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:
Trey t
2025-11-25 11:23:53 -06:00
parent f9e522f734
commit 7b0a0e5d85
21 changed files with 2316 additions and 549 deletions

View File

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

View File

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