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) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.android.billingclient.api.ProductDetails
|
import com.android.billingclient.api.ProductDetails
|
||||||
import com.tt.honeyDue.data.DataManager
|
import com.tt.honeyDue.data.DataManager
|
||||||
|
import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen
|
||||||
import com.tt.honeyDue.platform.BillingManager
|
import com.tt.honeyDue.platform.BillingManager
|
||||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -273,11 +274,12 @@ fun UpgradeFeatureScreenAndroid(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showFeatureComparison) {
|
if (showFeatureComparison) {
|
||||||
FeatureComparisonDialog(
|
// P2 Stream E — replaces FeatureComparisonDialog with the
|
||||||
onDismiss = { showFeatureComparison = false },
|
// shared full-screen FeatureComparisonScreen.
|
||||||
onUpgrade = {
|
FeatureComparisonScreen(
|
||||||
|
onNavigateBack = { showFeatureComparison = false },
|
||||||
|
onNavigateToUpgrade = {
|
||||||
showFeatureComparison = false
|
showFeatureComparison = false
|
||||||
// Select first product if available
|
|
||||||
products.firstOrNull()?.let { product ->
|
products.firstOrNull()?.let { product ->
|
||||||
selectedProductId = product.productId
|
selectedProductId = product.productId
|
||||||
activity?.let { act ->
|
activity?.let { act ->
|
||||||
@@ -289,7 +291,7 @@ fun UpgradeFeatureScreenAndroid(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import com.tt.honeyDue.ui.screens.TasksScreen
|
|||||||
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
|
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
|
||||||
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
|
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
|
||||||
import com.tt.honeyDue.ui.screens.onboarding.OnboardingScreen
|
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.ui.screens.theme.ThemeSelectionScreen
|
||||||
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
||||||
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
|
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
|
||||||
@@ -653,6 +654,17 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable<FeatureComparisonRoute> {
|
||||||
|
// P2 Stream E — full-screen Free vs. Pro comparison.
|
||||||
|
FeatureComparisonScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onNavigateToUpgrade = {
|
||||||
|
navController.popBackStack()
|
||||||
|
navController.navigate(UpgradeRoute)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
composable<EditTaskRoute> { backStackEntry ->
|
composable<EditTaskRoute> { backStackEntry ->
|
||||||
val route = backStackEntry.toRoute<EditTaskRoute>()
|
val route = backStackEntry.toRoute<EditTaskRoute>()
|
||||||
EditTaskScreen(
|
EditTaskScreen(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ object AnalyticsEvents {
|
|||||||
const val RESIDENCE_SCREEN_SHOWN = "residence_screen_shown"
|
const val RESIDENCE_SCREEN_SHOWN = "residence_screen_shown"
|
||||||
const val NEW_RESIDENCE_SCREEN_SHOWN = "new_residence_screen_shown"
|
const val NEW_RESIDENCE_SCREEN_SHOWN = "new_residence_screen_shown"
|
||||||
const val RESIDENCE_CREATED = "residence_created"
|
const val RESIDENCE_CREATED = "residence_created"
|
||||||
|
const val RESIDENCE_JOINED = "residence_joined"
|
||||||
const val RESIDENCE_LIMIT_REACHED = "residence_limit_reached"
|
const val RESIDENCE_LIMIT_REACHED = "residence_limit_reached"
|
||||||
|
|
||||||
// Task
|
// Task
|
||||||
@@ -74,4 +75,11 @@ object AnalyticsEvents {
|
|||||||
const val NOTIFICATION_SETTINGS_SCREEN_SHOWN = "notification_settings_screen_shown"
|
const val NOTIFICATION_SETTINGS_SCREEN_SHOWN = "notification_settings_screen_shown"
|
||||||
const val SETTINGS_SCREEN_SHOWN = "settings_screen_shown"
|
const val SETTINGS_SCREEN_SHOWN = "settings_screen_shown"
|
||||||
const val THEME_CHANGED = "theme_changed"
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ object ResidencesRoute
|
|||||||
@Serializable
|
@Serializable
|
||||||
object AddResidenceRoute
|
object AddResidenceRoute
|
||||||
|
|
||||||
|
// Full-screen join-via-share-code flow (P2 Stream F — replaces
|
||||||
|
// the old JoinResidenceDialog). Matches iOS JoinResidenceView.
|
||||||
|
@Serializable
|
||||||
|
object JoinResidenceRoute
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EditResidenceRoute(
|
data class EditResidenceRoute(
|
||||||
val residenceId: Int,
|
val residenceId: Int,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.tt.honeyDue.ui.components.ApiResultHandler
|
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.common.StatItem
|
||||||
import com.tt.honeyDue.ui.components.residence.TaskStatChip
|
import com.tt.honeyDue.ui.components.residence.TaskStatChip
|
||||||
import com.tt.honeyDue.viewmodel.ResidenceViewModel
|
import com.tt.honeyDue.viewmodel.ResidenceViewModel
|
||||||
@@ -45,6 +44,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
fun ResidencesScreen(
|
fun ResidencesScreen(
|
||||||
onResidenceClick: (Int) -> Unit,
|
onResidenceClick: (Int) -> Unit,
|
||||||
onAddResidence: () -> Unit,
|
onAddResidence: () -> Unit,
|
||||||
|
onJoinResidence: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onNavigateToProfile: () -> Unit = {},
|
onNavigateToProfile: () -> Unit = {},
|
||||||
shouldRefresh: Boolean = false,
|
shouldRefresh: Boolean = false,
|
||||||
@@ -53,7 +53,6 @@ fun ResidencesScreen(
|
|||||||
) {
|
) {
|
||||||
val myResidencesState by viewModel.myResidencesState.collectAsState()
|
val myResidencesState by viewModel.myResidencesState.collectAsState()
|
||||||
val totalSummary by DataManager.totalSummary.collectAsState()
|
val totalSummary by DataManager.totalSummary.collectAsState()
|
||||||
var showJoinDialog by remember { mutableStateOf(false) }
|
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
var showUpgradePrompt by remember { mutableStateOf(false) }
|
var showUpgradePrompt by remember { mutableStateOf(false) }
|
||||||
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
|
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -92,18 +91,6 @@ fun ResidencesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showJoinDialog) {
|
|
||||||
JoinResidenceDialog(
|
|
||||||
onDismiss = {
|
|
||||||
showJoinDialog = false
|
|
||||||
},
|
|
||||||
onJoined = {
|
|
||||||
// Reload residences after joining
|
|
||||||
viewModel.loadMyResidences()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showUpgradePrompt && upgradeTriggerKey != null) {
|
if (showUpgradePrompt && upgradeTriggerKey != null) {
|
||||||
UpgradePromptDialog(
|
UpgradePromptDialog(
|
||||||
triggerKey = upgradeTriggerKey!!,
|
triggerKey = upgradeTriggerKey!!,
|
||||||
@@ -143,7 +130,7 @@ fun ResidencesScreen(
|
|||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
val (allowed, triggerKey) = canAddProperty()
|
val (allowed, triggerKey) = canAddProperty()
|
||||||
if (allowed) {
|
if (allowed) {
|
||||||
showJoinDialog = true
|
onJoinResidence()
|
||||||
} else {
|
} else {
|
||||||
upgradeTriggerKey = triggerKey
|
upgradeTriggerKey = triggerKey
|
||||||
showUpgradePrompt = true
|
showUpgradePrompt = true
|
||||||
@@ -279,7 +266,7 @@ fun ResidencesScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
val (allowed, triggerKey) = canAddProperty()
|
val (allowed, triggerKey) = canAddProperty()
|
||||||
if (allowed) {
|
if (allowed) {
|
||||||
showJoinDialog = true
|
onJoinResidence()
|
||||||
} else {
|
} else {
|
||||||
upgradeTriggerKey = triggerKey
|
upgradeTriggerKey = triggerKey
|
||||||
showUpgradePrompt = true
|
showUpgradePrompt = true
|
||||||
|
|||||||
@@ -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<FeatureBenefit> = 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<FeatureBenefit>): List<FeatureBenefit> {
|
||||||
|
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<String, Any>?) -> Unit,
|
||||||
|
) {
|
||||||
|
captureEvent(AnalyticsEvents.PAYWALL_COMPARE_CTA, null)
|
||||||
|
onNavigateToUpgrade()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onClose(onBack: () -> Unit) {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tt.honeyDue.data.DataManager
|
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.AppRadius
|
||||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||||
import com.tt.honeyDue.utils.SubscriptionProducts
|
import com.tt.honeyDue.utils.SubscriptionProducts
|
||||||
@@ -191,12 +192,12 @@ fun UpgradeFeatureScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showFeatureComparison) {
|
if (showFeatureComparison) {
|
||||||
FeatureComparisonDialog(
|
// P2 Stream E — replaces the old FeatureComparisonDialog with
|
||||||
onDismiss = { showFeatureComparison = false },
|
// the full-screen FeatureComparisonScreen. Render as overlay
|
||||||
onUpgrade = {
|
// so dismiss returns to this paywall (matches iOS sheet).
|
||||||
// Trigger upgrade
|
FeatureComparisonScreen(
|
||||||
showFeatureComparison = false
|
onNavigateBack = { showFeatureComparison = false },
|
||||||
}
|
onNavigateToUpgrade = { showFeatureComparison = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.tt.honeyDue.data.DataManager
|
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.AppRadius
|
||||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||||
|
|
||||||
@@ -26,9 +27,15 @@ fun UpgradePromptDialog(
|
|||||||
var isProcessing by remember { mutableStateOf(false) }
|
var isProcessing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
if (showFeatureComparison) {
|
if (showFeatureComparison) {
|
||||||
FeatureComparisonDialog(
|
// P2 Stream E — migrated from FeatureComparisonDialog to the
|
||||||
onDismiss = { showFeatureComparison = false },
|
// full-screen FeatureComparisonScreen. Tapping the CTA invokes
|
||||||
onUpgrade = onUpgrade
|
// the existing onUpgrade flow (BillingManager / StoreKit).
|
||||||
|
FeatureComparisonScreen(
|
||||||
|
onNavigateBack = { showFeatureComparison = false },
|
||||||
|
onNavigateToUpgrade = {
|
||||||
|
showFeatureComparison = false
|
||||||
|
onUpgrade()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
|||||||
@@ -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<Pair<String, Map<String, Any>?>>()
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user