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:
Trey T
2026-04-18 13:12:21 -05:00
parent 224f6643bf
commit 944161f0d1
10 changed files with 566 additions and 217 deletions

View File

@@ -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<FeatureComparisonRoute> {
// P2 Stream E — full-screen Free vs. Pro comparison.
FeatureComparisonScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToUpgrade = {
navController.popBackStack()
navController.navigate(UpgradeRoute)
},
)
}
composable<EditTaskRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditTaskRoute>()
EditTaskScreen(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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