Phase 6: Add Android subscription UI with Jetpack Compose

Implemented Android subscription UI components:
- UpgradeFeatureScreen: Full-screen view for restricted features (contractors, documents)
  * Shows feature icon, name, and description
  * Displays "This feature is available with Pro" badge
  * Opens UpgradePromptDialog on button click
- UpgradePromptDialog: Modal dialog with upgrade options
  * Trigger-specific title and message from backend
  * Feature preview list with Material icons
  * "Upgrade to Pro" button with loading state
  * "Compare Free vs Pro" button
  * "Maybe Later" cancel option
- FeatureComparisonDialog: Full-screen comparison table
  * Free vs Pro feature comparison
  * Displays data from SubscriptionCache.featureBenefits
  * Default features if no data loaded
  * Upgrade button
- BillingManager: Google Play Billing Library placeholder
  * Singleton manager for in-app purchases
  * Product query placeholder
  * Purchase flow placeholder
  * Backend receipt verification placeholder
  * Restore purchases placeholder

All components follow Material3 design system with theme-aware colors and spacing constants.

🤖 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-24 13:41:06 -06:00
parent d12a2d315c
commit bf2e572abf
4 changed files with 617 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
package com.example.mycrib.platform
import android.app.Activity
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Google Play Billing manager for in-app purchases
* NOTE: Requires Google Play Console configuration and product IDs
*/
class BillingManager private constructor(context: Context) {
companion object {
@Volatile
private var INSTANCE: BillingManager? = null
fun getInstance(context: Context): BillingManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: BillingManager(context.applicationContext).also { INSTANCE = it }
}
}
}
// Product ID for Pro subscription (configure in Google Play Console)
private val proSubscriptionProductID = "com.example.mycrib.pro.monthly"
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _purchasedProductIDs = MutableStateFlow<Set<String>>(emptySet())
val purchasedProductIDs: StateFlow<Set<String>> = _purchasedProductIDs
init {
// Start listening for purchases
// In production, initialize BillingClient here
println("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 */ }
// })
println("BillingManager: Would connect to Google Play Billing")
onSuccess()
}
/**
* Query available products
*/
suspend fun queryProducts(): List<ProductDetails> {
_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)
println("BillingManager: Would query products here")
return emptyList()
} finally {
_isLoading.value = false
}
}
/**
* Launch purchase flow
*/
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)
println("BillingManager: Would launch purchase flow for: $productId")
onError("Purchase not implemented yet")
}
/**
* Verify purchase with backend
*/
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
// )
println("BillingManager: Would verify purchase with backend")
}
/**
* Restore purchases
*/
suspend fun restorePurchases() {
// In production, this would query purchase history
// val result = billingClient.queryPurchasesAsync(
// QueryPurchasesParams.newBuilder()
// .setProductType(BillingClient.ProductType.SUBS)
// .build()
// )
println("BillingManager: Would restore purchases here")
}
/**
* Acknowledge purchase (required by Google Play)
*/
private suspend fun acknowledgePurchase(purchaseToken: String) {
// In production, this would acknowledge the purchase
// val params = AcknowledgePurchaseParams.newBuilder()
// .setPurchaseToken(purchaseToken)
// .build()
// billingClient.acknowledgePurchase(params)
println("BillingManager: Would acknowledge purchase")
}
}
/**
* 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,188 @@
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.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.mycrib.cache.SubscriptionCache
import com.example.mycrib.ui.theme.AppRadius
import com.example.mycrib.ui.theme.AppSpacing
@Composable
fun FeatureComparisonDialog(
onDismiss: () -> Unit,
onUpgrade: () -> Unit
) {
val subscriptionCache = SubscriptionCache
val featureBenefits = subscriptionCache.featureBenefits.value
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f)
.padding(AppSpacing.md),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Choose Your Plan",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
Text(
"Upgrade to Pro for unlimited access",
modifier = Modifier.padding(horizontal = AppSpacing.lg),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(AppSpacing.lg))
// Comparison Table
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
// Header Row
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Feature",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
"Free",
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Pro",
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
}
}
HorizontalDivider()
// Feature Rows
if (featureBenefits.isNotEmpty()) {
featureBenefits.forEach { benefit ->
ComparisonRow(
featureName = benefit.featureName,
freeText = benefit.freeTier,
proText = benefit.proTier
)
HorizontalDivider()
}
} else {
// Default features if no data loaded
ComparisonRow("Properties", "1 property", "Unlimited")
HorizontalDivider()
ComparisonRow("Tasks", "10 tasks", "Unlimited")
HorizontalDivider()
ComparisonRow("Contractors", "Not available", "Unlimited")
HorizontalDivider()
ComparisonRow("Documents", "Not available", "Unlimited")
HorizontalDivider()
}
}
// Upgrade Button
Button(
onClick = {
onUpgrade()
onDismiss()
},
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
shape = MaterialTheme.shapes.medium
) {
Text("Upgrade to Pro", fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
private fun ComparisonRow(
featureName: String,
freeText: String,
proText: String
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
featureName,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
Text(
freeText,
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Text(
proText,
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
}
}

View File

@@ -0,0 +1,123 @@
package com.example.mycrib.ui.subscription
import androidx.compose.foundation.layout.*
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.mycrib.ui.theme.AppRadius
import com.example.mycrib.ui.theme.AppSpacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpgradeFeatureScreen(
triggerKey: String,
featureName: String,
featureDescription: String,
icon: ImageVector,
onNavigateBack: () -> Unit
) {
var showUpgradeDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(featureName, fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg)
) {
// Feature Icon
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
// Title
Text(
featureName,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
// Description
Text(
featureDescription,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
// Upgrade Badge
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.tertiaryContainer
) {
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
)
}
Spacer(Modifier.height(AppSpacing.lg))
// Upgrade Button
Button(
onClick = { showUpgradeDialog = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
shape = MaterialTheme.shapes.medium
) {
Text("Upgrade to Pro", fontWeight = FontWeight.Bold)
}
}
}
if (showUpgradeDialog) {
UpgradePromptDialog(
triggerKey = triggerKey,
onDismiss = { showUpgradeDialog = false },
onUpgrade = {
// TODO: Trigger Google Play Billing
showUpgradeDialog = false
}
)
}
}
}

View File

@@ -0,0 +1,155 @@
package com.example.mycrib.ui.subscription
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.mycrib.cache.SubscriptionCache
import com.example.mycrib.ui.theme.AppRadius
import com.example.mycrib.ui.theme.AppSpacing
@Composable
fun UpgradePromptDialog(
triggerKey: String,
onDismiss: () -> Unit,
onUpgrade: () -> Unit
) {
val subscriptionCache = SubscriptionCache
val triggerData = subscriptionCache.upgradeTriggers.value[triggerKey]
var showFeatureComparison by remember { mutableStateOf(false) }
var isProcessing by remember { mutableStateOf(false) }
if (showFeatureComparison) {
FeatureComparisonDialog(
onDismiss = { showFeatureComparison = false },
onUpgrade = onUpgrade
)
} else {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Icon
Icon(
imageVector = Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.tertiary
)
// Title
Text(
triggerData?.title ?: "Upgrade to Pro",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
// Message
Text(
triggerData?.message ?: "Unlock unlimited access to all features",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(AppSpacing.sm))
// Pro Features Preview
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
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.sm))
// Upgrade Button
Button(
onClick = {
isProcessing = true
onUpgrade()
},
modifier = Modifier.fillMaxWidth(),
enabled = !isProcessing,
shape = MaterialTheme.shapes.medium
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
triggerData?.buttonText ?: "Upgrade to Pro",
fontWeight = FontWeight.Bold
)
}
}
// Compare Plans
TextButton(onClick = { showFeatureComparison = true }) {
Text("Compare Free vs Pro")
}
// Cancel
TextButton(onClick = onDismiss) {
Text("Maybe Later")
}
}
}
}
}
}
@Composable
private fun FeatureRow(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text,
style = MaterialTheme.typography.bodyMedium
)
}
}