From 944161f0d159de340ab8bfb02c4928e10d74a547 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 13:12:21 -0500 Subject: [PATCH] P2 Stream E: FeatureComparisonScreen (replaces FeatureComparisonDialog) Full-screen feature comparison matching iOS FeatureComparisonView. Two-column table, iOS-equivalent row set, CTA to upgrade flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UpgradeFeatureScreenAndroid.kt | 12 +- .../commonMain/kotlin/com/tt/honeyDue/App.kt | 12 + .../com/tt/honeyDue/analytics/Analytics.kt | 8 + .../com/tt/honeyDue/navigation/Routes.kt | 5 + .../honeyDue/ui/screens/ResidencesScreen.kt | 19 +- .../subscription/FeatureComparisonScreen.kt | 382 ++++++++++++++++++ .../subscription/FeatureComparisonDialog.kt | 187 --------- .../ui/subscription/UpgradeFeatureScreen.kt | 13 +- .../ui/subscription/UpgradePromptDialog.kt | 13 +- .../FeatureComparisonScreenTest.kt | 132 ++++++ 10 files changed, 566 insertions(+), 217 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/subscription/FeatureComparisonScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/FeatureComparisonDialog.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/subscription/FeatureComparisonScreenTest.kt diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradeFeatureScreenAndroid.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradeFeatureScreenAndroid.kt index 9d44dee..120b89d 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradeFeatureScreenAndroid.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradeFeatureScreenAndroid.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.android.billingclient.api.ProductDetails import com.tt.honeyDue.data.DataManager +import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen import com.tt.honeyDue.platform.BillingManager import com.tt.honeyDue.ui.theme.AppSpacing import kotlinx.coroutines.launch @@ -273,11 +274,12 @@ fun UpgradeFeatureScreenAndroid( } if (showFeatureComparison) { - FeatureComparisonDialog( - onDismiss = { showFeatureComparison = false }, - onUpgrade = { + // P2 Stream E — replaces FeatureComparisonDialog with the + // shared full-screen FeatureComparisonScreen. + FeatureComparisonScreen( + onNavigateBack = { showFeatureComparison = false }, + onNavigateToUpgrade = { showFeatureComparison = false - // Select first product if available products.firstOrNull()?.let { product -> selectedProductId = product.productId activity?.let { act -> @@ -289,7 +291,7 @@ fun UpgradeFeatureScreenAndroid( ) } } - } + }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt index be2af48..318d25b 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt @@ -30,6 +30,7 @@ import com.tt.honeyDue.ui.screens.TasksScreen import com.tt.honeyDue.ui.screens.VerifyEmailScreen import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen import com.tt.honeyDue.ui.screens.onboarding.OnboardingScreen +import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen import com.tt.honeyDue.viewmodel.OnboardingViewModel import com.tt.honeyDue.viewmodel.PasswordResetViewModel @@ -653,6 +654,17 @@ fun App( ) } + composable { + // P2 Stream E — full-screen Free vs. Pro comparison. + FeatureComparisonScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToUpgrade = { + navController.popBackStack() + navController.navigate(UpgradeRoute) + }, + ) + } + composable { backStackEntry -> val route = backStackEntry.toRoute() EditTaskScreen( diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt index e3b530e..c646d92 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt @@ -29,6 +29,7 @@ object AnalyticsEvents { const val RESIDENCE_SCREEN_SHOWN = "residence_screen_shown" const val NEW_RESIDENCE_SCREEN_SHOWN = "new_residence_screen_shown" const val RESIDENCE_CREATED = "residence_created" + const val RESIDENCE_JOINED = "residence_joined" const val RESIDENCE_LIMIT_REACHED = "residence_limit_reached" // Task @@ -74,4 +75,11 @@ object AnalyticsEvents { const val NOTIFICATION_SETTINGS_SCREEN_SHOWN = "notification_settings_screen_shown" const val SETTINGS_SCREEN_SHOWN = "settings_screen_shown" const val THEME_CHANGED = "theme_changed" + + // Subscription / Paywall + // PAYWALL_COMPARE_CTA: fired when the user taps the "Upgrade to Pro" CTA + // from the FeatureComparisonScreen (the full-screen Free vs. Pro + // comparison table). Lets the funnel attribute conversions to the + // comparison surface rather than the generic upgrade entry point. + const val PAYWALL_COMPARE_CTA = "paywall_compare_cta" } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt index ca4f461..fe6b72d 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt @@ -21,6 +21,11 @@ object ResidencesRoute @Serializable object AddResidenceRoute +// Full-screen join-via-share-code flow (P2 Stream F — replaces +// the old JoinResidenceDialog). Matches iOS JoinResidenceView. +@Serializable +object JoinResidenceRoute + @Serializable data class EditResidenceRoute( val residenceId: Int, diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResidencesScreen.kt index 4514009..4ce635d 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ResidencesScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.tt.honeyDue.ui.components.ApiResultHandler -import com.tt.honeyDue.ui.components.JoinResidenceDialog import com.tt.honeyDue.ui.components.common.StatItem import com.tt.honeyDue.ui.components.residence.TaskStatChip import com.tt.honeyDue.viewmodel.ResidenceViewModel @@ -45,6 +44,7 @@ import org.jetbrains.compose.resources.stringResource fun ResidencesScreen( onResidenceClick: (Int) -> Unit, onAddResidence: () -> Unit, + onJoinResidence: () -> Unit, onLogout: () -> Unit, onNavigateToProfile: () -> Unit = {}, shouldRefresh: Boolean = false, @@ -53,7 +53,6 @@ fun ResidencesScreen( ) { val myResidencesState by viewModel.myResidencesState.collectAsState() val totalSummary by DataManager.totalSummary.collectAsState() - var showJoinDialog by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) } var showUpgradePrompt by remember { mutableStateOf(false) } var upgradeTriggerKey by remember { mutableStateOf(null) } @@ -92,18 +91,6 @@ fun ResidencesScreen( } } - if (showJoinDialog) { - JoinResidenceDialog( - onDismiss = { - showJoinDialog = false - }, - onJoined = { - // Reload residences after joining - viewModel.loadMyResidences() - } - ) - } - if (showUpgradePrompt && upgradeTriggerKey != null) { UpgradePromptDialog( triggerKey = upgradeTriggerKey!!, @@ -143,7 +130,7 @@ fun ResidencesScreen( IconButton(onClick = { val (allowed, triggerKey) = canAddProperty() if (allowed) { - showJoinDialog = true + onJoinResidence() } else { upgradeTriggerKey = triggerKey showUpgradePrompt = true @@ -279,7 +266,7 @@ fun ResidencesScreen( onClick = { val (allowed, triggerKey) = canAddProperty() if (allowed) { - showJoinDialog = true + onJoinResidence() } else { upgradeTriggerKey = triggerKey showUpgradePrompt = true diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/subscription/FeatureComparisonScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/subscription/FeatureComparisonScreen.kt new file mode 100644 index 0000000..ecdcc3c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/subscription/FeatureComparisonScreen.kt @@ -0,0 +1,382 @@ +package com.tt.honeyDue.ui.screens.subscription + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.collectAsState +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 com.tt.honeyDue.analytics.AnalyticsEvents +import com.tt.honeyDue.analytics.PostHogAnalytics +import com.tt.honeyDue.data.DataManager +import com.tt.honeyDue.models.FeatureBenefit +import com.tt.honeyDue.ui.components.common.StandardCard +import com.tt.honeyDue.ui.theme.AppSpacing + +/** + * FeatureComparisonScreen — full-screen Free vs. Pro comparison matching + * iOS `iosApp/iosApp/Subscription/FeatureComparisonView.swift`. + * + * Replaces the old `ui/subscription/FeatureComparisonDialog.kt` (deleted + * in P2 Stream E). The iOS view is a navigation-stack destination, not a + * dialog — this screen mirrors that layout: + * + * - Header ("Choose Your Plan" + subtitle) + * - Two-column comparison table rendered inside a [StandardCard] + * (Feature | Free | Pro) with N rows sourced from + * `DataManager.featureBenefits`, falling back to a 4-row default list. + * - CTA button at the bottom ("Upgrade to Pro") which fires analytics + * event [AnalyticsEvents.PAYWALL_COMPARE_CTA] and invokes + * [onNavigateToUpgrade]. + * - Toolbar back button dismisses. + * + * Testable behavior lives on [FeatureComparisonScreenState] so it can be + * exercised in `commonTest` without standing up the Compose recomposer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FeatureComparisonScreen( + onNavigateBack: () -> Unit, + onNavigateToUpgrade: () -> Unit, +) { + val benefits by DataManager.featureBenefits.collectAsState() + val rows = FeatureComparisonScreenState.resolveFeatureRows(benefits) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Choose Your Plan", + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { + IconButton(onClick = { + FeatureComparisonScreenState.onClose(onBack = onNavigateBack) + }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { paddingValues: PaddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md), + verticalArrangement = Arrangement.spacedBy(AppSpacing.lg), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.sm), + ) { + Text( + text = "Choose Your Plan", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "Upgrade to Pro for unlimited access", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + StandardCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = 0.dp, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + ComparisonHeaderRow() + HorizontalDivider() + + rows.forEachIndexed { index, row -> + ComparisonRow( + featureName = row.featureName, + freeText = row.freeTierText, + proText = row.proTierText, + freeHas = FeatureComparisonScreenState.freeHasFeature(row), + proHas = FeatureComparisonScreenState.premiumHasFeature(row), + ) + if (index != rows.lastIndex) { + HorizontalDivider() + } + } + } + } + + Button( + onClick = { + FeatureComparisonScreenState.onUpgradeTap( + onNavigateToUpgrade = onNavigateToUpgrade, + captureEvent = { event, props -> + PostHogAnalytics.capture(event, props) + }, + ) + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = MaterialTheme.shapes.medium, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = "Upgrade to Pro", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + + Spacer(modifier = Modifier.height(AppSpacing.lg)) + } + } +} + +@Composable +private fun ComparisonHeaderRow() { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Feature", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Free", + modifier = Modifier.width(80.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Pro", + modifier = Modifier.width(80.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun ComparisonRow( + featureName: String, + freeText: String, + proText: String, + freeHas: Boolean, + proHas: Boolean, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(AppSpacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = featureName, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Box( + modifier = Modifier.width(80.dp), + contentAlignment = Alignment.Center, + ) { + TierCell( + text = freeText, + hasFeature = freeHas, + emphasize = false, + ) + } + + Box( + modifier = Modifier.width(80.dp), + contentAlignment = Alignment.Center, + ) { + TierCell( + text = proText, + hasFeature = proHas, + emphasize = true, + ) + } + } +} + +/** + * Single tier cell showing a check/cross glyph stacked with the limit + * text. Matches the iOS `ComparisonRow` visual language where "Not + * available" reads as the absence of the feature. + */ +@Composable +private fun TierCell( + text: String, + hasFeature: Boolean, + emphasize: Boolean, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.xs), + ) { + Icon( + imageVector = if (hasFeature) Icons.Default.Check else Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (hasFeature) { + if (emphasize) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + fontWeight = if (emphasize) FontWeight.Medium else FontWeight.Normal, + color = if (emphasize && hasFeature) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +/** + * State helper for [FeatureComparisonScreen]. + * + * Pulled out as a plain object so the behavior is unit-testable in + * `commonTest` without standing up the Compose recomposer — same pattern + * as [com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreenState]. + * + * The iOS reference (`FeatureComparisonView.swift`) sources rows from + * `SubscriptionCacheWrapper.shared.featureBenefits` with a 4-row default + * list when the cache is empty. This object exposes: + * + * - [defaultFeatureRows] — the same 4-row default (Properties, Tasks, + * Contractors, Documents). + * - [resolveFeatureRows] — server-driven benefits if present, default + * otherwise. + * - [freeHasFeature] / [premiumHasFeature] — per-row booleans matching + * iOS: a tier "has" the feature unless its text reads "Not available" + * (case-insensitive). + * - [onUpgradeTap] — fires [AnalyticsEvents.PAYWALL_COMPARE_CTA] and + * navigates to the upgrade flow. + * - [onClose] — dismisses the screen. + */ +object FeatureComparisonScreenState { + + fun defaultFeatureRows(): List = listOf( + FeatureBenefit( + featureName = "Properties", + freeTierText = "1 property", + proTierText = "Unlimited", + ), + FeatureBenefit( + featureName = "Tasks", + freeTierText = "10 tasks", + proTierText = "Unlimited", + ), + FeatureBenefit( + featureName = "Contractors", + freeTierText = "Not available", + proTierText = "Unlimited", + ), + FeatureBenefit( + featureName = "Documents", + freeTierText = "Not available", + proTierText = "Unlimited", + ), + ) + + fun resolveFeatureRows(serverBenefits: List): List { + return if (serverBenefits.isNotEmpty()) serverBenefits else defaultFeatureRows() + } + + fun freeHasFeature(benefit: FeatureBenefit): Boolean = + !isUnavailable(benefit.freeTierText) + + fun premiumHasFeature(benefit: FeatureBenefit): Boolean = + !isUnavailable(benefit.proTierText) + + private fun isUnavailable(text: String): Boolean = + text.trim().equals("Not available", ignoreCase = true) + + /** + * CTA handler. Fires the paywall analytics event and navigates to + * the upgrade flow. The [captureEvent] parameter is injected so tests + * can record without touching the PostHog SDK. + */ + fun onUpgradeTap( + onNavigateToUpgrade: () -> Unit, + captureEvent: (String, Map?) -> Unit, + ) { + captureEvent(AnalyticsEvents.PAYWALL_COMPARE_CTA, null) + onNavigateToUpgrade() + } + + fun onClose(onBack: () -> Unit) { + onBack() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/FeatureComparisonDialog.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/FeatureComparisonDialog.kt deleted file mode 100644 index e54edd7..0000000 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/FeatureComparisonDialog.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.tt.honeyDue.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.tt.honeyDue.data.DataManager -import com.tt.honeyDue.ui.theme.AppRadius -import com.tt.honeyDue.ui.theme.AppSpacing - -@Composable -fun FeatureComparisonDialog( - onDismiss: () -> Unit, - onUpgrade: () -> Unit -) { - val featureBenefits = DataManager.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.freeTierText, - proText = benefit.proTierText - ) - 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/tt/honeyDue/ui/subscription/UpgradeFeatureScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradeFeatureScreen.kt index 744b3b2..6240e1f 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradeFeatureScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradeFeatureScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.tt.honeyDue.data.DataManager +import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen import com.tt.honeyDue.ui.theme.AppRadius import com.tt.honeyDue.ui.theme.AppSpacing import com.tt.honeyDue.utils.SubscriptionProducts @@ -191,12 +192,12 @@ fun UpgradeFeatureScreen( } if (showFeatureComparison) { - FeatureComparisonDialog( - onDismiss = { showFeatureComparison = false }, - onUpgrade = { - // Trigger upgrade - showFeatureComparison = false - } + // P2 Stream E — replaces the old FeatureComparisonDialog with + // the full-screen FeatureComparisonScreen. Render as overlay + // so dismiss returns to this paywall (matches iOS sheet). + FeatureComparisonScreen( + onNavigateBack = { showFeatureComparison = false }, + onNavigateToUpgrade = { showFeatureComparison = false }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradePromptDialog.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradePromptDialog.kt index d893d44..701bc80 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradePromptDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/subscription/UpgradePromptDialog.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.tt.honeyDue.data.DataManager +import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen import com.tt.honeyDue.ui.theme.AppRadius import com.tt.honeyDue.ui.theme.AppSpacing @@ -26,9 +27,15 @@ fun UpgradePromptDialog( var isProcessing by remember { mutableStateOf(false) } if (showFeatureComparison) { - FeatureComparisonDialog( - onDismiss = { showFeatureComparison = false }, - onUpgrade = onUpgrade + // P2 Stream E — migrated from FeatureComparisonDialog to the + // full-screen FeatureComparisonScreen. Tapping the CTA invokes + // the existing onUpgrade flow (BillingManager / StoreKit). + FeatureComparisonScreen( + onNavigateBack = { showFeatureComparison = false }, + onNavigateToUpgrade = { + showFeatureComparison = false + onUpgrade() + }, ) } else { Dialog(onDismissRequest = onDismiss) { diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/subscription/FeatureComparisonScreenTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/subscription/FeatureComparisonScreenTest.kt new file mode 100644 index 0000000..e30d348 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/subscription/FeatureComparisonScreenTest.kt @@ -0,0 +1,132 @@ +package com.tt.honeyDue.ui.screens.subscription + +import com.tt.honeyDue.analytics.AnalyticsEvents +import com.tt.honeyDue.models.FeatureBenefit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * P2 Stream E — FeatureComparisonScreen tests. + * + * These tests exercise the state-logic backing the full-screen feature + * comparison (replaces the old FeatureComparisonDialog). They use plain + * kotlin.test rather than Compose UI testing for the same reasons cited + * in ThemeSelectionScreenTest — the commonTest recomposer/Dispatchers + * interplay is flaky on iosSimulator. + * + * Mirrors iOS `iosApp/iosApp/Subscription/FeatureComparisonView.swift`. + */ +class FeatureComparisonScreenTest { + + // 1. The default feature-row set matches iOS FeatureComparisonView.swift + // lines 99-105 (shown when DataManager.featureBenefits is empty). + @Test + fun defaultFeatureRowsMatchIOSOrderAndText() { + val rows = FeatureComparisonScreenState.defaultFeatureRows() + + assertEquals(4, rows.size, "iOS default list has 4 rows") + + assertEquals("Properties", rows[0].featureName) + assertEquals("1 property", rows[0].freeTierText) + assertEquals("Unlimited", rows[0].proTierText) + + assertEquals("Tasks", rows[1].featureName) + assertEquals("10 tasks", rows[1].freeTierText) + assertEquals("Unlimited", rows[1].proTierText) + + assertEquals("Contractors", rows[2].featureName) + assertEquals("Not available", rows[2].freeTierText) + assertEquals("Unlimited", rows[2].proTierText) + + assertEquals("Documents", rows[3].featureName) + assertEquals("Not available", rows[3].freeTierText) + assertEquals("Unlimited", rows[3].proTierText) + } + + // 2. freeHasFeature returns false for "Not available" rows (the iOS + // comparison renders the free column grey for those), true for + // rows with an actual limit text like "1 property". + @Test + fun freeHasFeatureFollowsIosPerRowBooleans() { + val rows = FeatureComparisonScreenState.defaultFeatureRows() + + assertTrue(FeatureComparisonScreenState.freeHasFeature(rows[0]), "Properties: free has 1") + assertTrue(FeatureComparisonScreenState.freeHasFeature(rows[1]), "Tasks: free has 10") + assertFalse(FeatureComparisonScreenState.freeHasFeature(rows[2]), "Contractors: free has none") + assertFalse(FeatureComparisonScreenState.freeHasFeature(rows[3]), "Documents: free has none") + } + + // 3. premiumHasFeature is true for every row on iOS — Pro is unlimited + // across the default set and every server-driven benefit. + @Test + fun premiumHasFeatureAlwaysTrueForProTier() { + val rows = FeatureComparisonScreenState.defaultFeatureRows() + rows.forEach { row -> + assertTrue( + FeatureComparisonScreenState.premiumHasFeature(row), + "Pro always has ${row.featureName}" + ) + } + + // Server-driven benefits: Pro tier is true unless the text is + // explicitly "Not available" (treated the same as the Free column). + val benefit = FeatureBenefit( + featureName = "Reports", + freeTierText = "Not available", + proTierText = "Not available" + ) + assertFalse(FeatureComparisonScreenState.premiumHasFeature(benefit)) + } + + // 4. CTA invocation calls the expected navigation callback. + @Test + fun ctaInvokesUpgradeNavigationCallback() { + var navigated = false + FeatureComparisonScreenState.onUpgradeTap( + onNavigateToUpgrade = { navigated = true }, + captureEvent = { _, _ -> /* ignore */ }, + ) + assertTrue(navigated, "Upgrade CTA must navigate to upgrade flow") + } + + // 5. Upgrade CTA fires analytics event paywall_compare_cta. + @Test + fun ctaFiresPaywallCompareAnalyticsEvent() { + val captured = mutableListOf?>>() + FeatureComparisonScreenState.onUpgradeTap( + onNavigateToUpgrade = { }, + captureEvent = { event, props -> captured.add(event to props) }, + ) + assertEquals(1, captured.size, "Exactly one analytics event") + assertEquals(AnalyticsEvents.PAYWALL_COMPARE_CTA, captured[0].first) + } + + // 6. Close / back callback is invoked when the user dismisses the + // screen (matches iOS "Close" toolbar button). + @Test + fun onCloseInvokesBackCallback() { + var closed = false + FeatureComparisonScreenState.onClose(onBack = { closed = true }) + assertTrue(closed, "Close/Back must call the onBack callback") + } + + // 7. When DataManager has server-driven benefits, the screen uses + // those in preference to the default list. + @Test + fun serverDrivenBenefitsOverrideDefaults() { + val serverBenefits = listOf( + FeatureBenefit("Custom Feature A", "Not available", "Unlimited"), + FeatureBenefit("Custom Feature B", "5 items", "Unlimited"), + ) + val effective = FeatureComparisonScreenState.resolveFeatureRows(serverBenefits) + assertEquals(2, effective.size) + assertEquals("Custom Feature A", effective[0].featureName) + assertFalse(FeatureComparisonScreenState.freeHasFeature(effective[0])) + assertTrue(FeatureComparisonScreenState.freeHasFeature(effective[1])) + + val empty = FeatureComparisonScreenState.resolveFeatureRows(emptyList()) + assertEquals(4, empty.size, "Empty benefits falls back to default iOS list") + } +}