From bf2e572abf73f582fc19d816bfd849f571622c2f Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 24 Nov 2025 13:41:06 -0600 Subject: [PATCH] Phase 6: Add Android subscription UI with Jetpack Compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../example/mycrib/platform/BillingManager.kt | 151 ++++++++++++++ .../subscription/FeatureComparisonDialog.kt | 188 ++++++++++++++++++ .../ui/subscription/UpgradeFeatureScreen.kt | 123 ++++++++++++ .../ui/subscription/UpgradePromptDialog.kt | 155 +++++++++++++++ 4 files changed, 617 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/com/example/mycrib/platform/BillingManager.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/FeatureComparisonDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradePromptDialog.kt diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/BillingManager.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/BillingManager.kt new file mode 100644 index 0000000..5f1993e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/BillingManager.kt @@ -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 = _isLoading + + private val _purchasedProductIDs = MutableStateFlow>(emptySet()) + val purchasedProductIDs: StateFlow> = _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 { + _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 +) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/FeatureComparisonDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/FeatureComparisonDialog.kt new file mode 100644 index 0000000..bb73bd0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/FeatureComparisonDialog.kt @@ -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 + ) + } +} 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 new file mode 100644 index 0000000..6bdb28b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradeFeatureScreen.kt @@ -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 + } + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradePromptDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradePromptDialog.kt new file mode 100644 index 0000000..c558b69 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/subscription/UpgradePromptDialog.kt @@ -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 + ) + } +}