feat: subscription screen with clear pricing, restore, cancel instructions

This commit is contained in:
null 2026-06-21 09:54:59 -05:00
parent dff86eb089
commit 62d99505c9
3 changed files with 425 additions and 26 deletions

View File

@ -13,6 +13,7 @@ object ExternalLinks {
// TODO: Update placeholder URL before production. // TODO: Update placeholder URL before production.
const val SUBSCRIPTION_TERMS = "https://closer.app/subscription-terms" const val SUBSCRIPTION_TERMS = "https://closer.app/subscription-terms"
const val SUPPORT = "https://closer.app/support" const val SUPPORT = "https://closer.app/support"
const val MANAGE_SUBSCRIPTION = "https://play.google.com/store/account/subscriptions?package=app.closer"
fun openUrl(context: Context, url: String) { fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))

View File

@ -1,35 +1,429 @@
package app.closer.ui.settings package app.closer.ui.settings
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.ui.components.FinishedEmptyStateAction import app.closer.core.navigation.ExternalLinks
import app.closer.ui.components.FinishedEmptyStateScreen import app.closer.domain.repository.BillingRepository
import app.closer.domain.repository.BillingState
import app.closer.ui.components.LoadingState
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerCardColor
import com.revenuecat.purchases.CustomerInfo
import dagger.hilt.android.lifecycle.HiltViewModel
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
private val BENEFITS = listOf(
"Unlimited questions every day",
"Every premium question pack",
"Full answer history and insights",
"Date planning and bucket list",
"Connection Challenges and Desire Sync",
"Memory Lane time capsules",
)
data class SubscriptionUiState(
val isLoading: Boolean = true,
val isPremium: Boolean = false,
val renewalDateLabel: String? = null,
val isRestoring: Boolean = false,
val restoreSuccess: Boolean = false,
val restoreError: String? = null,
)
@HiltViewModel
class SubscriptionViewModel @Inject constructor(
private val entitlementChecker: EntitlementChecker,
private val billingRepository: BillingRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(SubscriptionUiState())
val uiState: StateFlow<SubscriptionUiState> = _uiState.asStateFlow()
init {
entitlementChecker.isPremium()
.onEach { premium -> _uiState.update { it.copy(isLoading = false, isPremium = premium) } }
.launchIn(viewModelScope)
billingRepository.getCustomerInfo()
.onEach { state ->
if (state is BillingState.Success) {
_uiState.update { it.copy(renewalDateLabel = state.data.renewalLabel()) }
}
}
.launchIn(viewModelScope)
}
fun restore() {
if (_uiState.value.isRestoring) return
_uiState.update { it.copy(isRestoring = true, restoreError = null) }
viewModelScope.launch {
when (val result = billingRepository.restorePurchases()) {
is BillingState.Success -> _uiState.update {
it.copy(isRestoring = false, restoreSuccess = true)
}
is BillingState.Error -> {
Log.w("SubscriptionVM", "Restore failed: ${result.message}")
_uiState.update { it.copy(isRestoring = false, restoreError = result.message) }
}
else -> _uiState.update { it.copy(isRestoring = false) }
}
}
}
fun consumeRestoreSuccess() = _uiState.update { it.copy(restoreSuccess = false) }
fun consumeRestoreError() = _uiState.update { it.copy(restoreError = null) }
}
private fun CustomerInfo.renewalLabel(): String? {
val expiry = latestExpirationDate ?: return null
val fmt = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
return "Renews ${fmt.format(expiry)}"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SubscriptionScreen( fun SubscriptionScreen(
onNavigate: (String) -> Unit = {} onNavigate: (String) -> Unit = {},
viewModel: SubscriptionViewModel = hiltViewModel()
) { ) {
FinishedEmptyStateScreen( val state by viewModel.uiState.collectAsState()
eyebrow = "Subscription", val snackbar = remember { SnackbarHostState() }
title = "Manage access from the paywall", val context = LocalContext.current
body = "Plan details, restore purchases, and upgrades live together so billing choices stay clear.",
glyphCategoryId = "star", LaunchedEffect(state.restoreSuccess) {
primaryAction = FinishedEmptyStateAction("Open paywall", AppRoute.PAYWALL), if (state.restoreSuccess) {
secondaryAction = FinishedEmptyStateAction("Back to settings", AppRoute.SETTINGS), snackbar.showSnackbar("Purchases restored.")
accent = CloserPalette.PurpleDeep, viewModel.consumeRestoreSuccess()
details = listOf( }
"Review the upgrade path before making a choice.", }
"Restore purchase access from the same flow.", LaunchedEffect(state.restoreError) {
"Keep account settings separate from billing decisions." state.restoreError?.let {
), snackbar.showSnackbar(it)
onNavigate = onNavigate viewModel.consumeRestoreError()
) }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent,
modifier = Modifier.background(closerBackgroundBrush()),
topBar = {
TopAppBar(
title = { Text("Subscription", color = SettingsInk) },
navigationIcon = {
IconButton(onClick = { onNavigate("back") }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = SettingsInk
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
if (state.isLoading) {
LoadingState(modifier = Modifier.fillMaxSize().padding(padding))
} else if (state.isPremium) {
PremiumContent(
state = state,
onManage = { ExternalLinks.openUrl(context, ExternalLinks.MANAGE_SUBSCRIPTION) },
onRestore = viewModel::restore,
modifier = Modifier.padding(padding)
)
} else {
FreeContent(
state = state,
onUpgrade = { onNavigate(AppRoute.PAYWALL) },
onRestore = viewModel::restore,
modifier = Modifier.padding(padding)
)
}
}
} }
@Preview
@Composable @Composable
fun SubscriptionScreenPreview() { private fun PremiumContent(
SubscriptionScreen() state: SubscriptionUiState,
onManage: () -> Unit,
onRestore: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = CloserPalette.PurpleDeep.copy(alpha = 0.08f),
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = CloserPalette.PurpleDeep,
modifier = Modifier.size(56.dp)
) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.padding(14.dp)
.size(28.dp)
)
}
Text(
text = "You're Premium",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
state.renewalDateLabel?.let { label ->
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = closerCardColor(alpha = 0.9f),
shadowElevation = 2.dp
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "What's included",
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
BENEFITS.forEach { benefit ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = CloserPalette.PurpleDeep,
modifier = Modifier.size(16.dp)
)
Text(
text = benefit,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
Button(
onClick = onManage,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = CloserPalette.PurpleDeep,
contentColor = Color.White
)
) {
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.size(6.dp))
Text("Manage subscription", fontWeight = FontWeight.SemiBold)
}
TextButton(
onClick = onRestore,
enabled = !state.isRestoring,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (state.isRestoring) "Restoring…" else "Restore purchases",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun FreeContent(
state: SubscriptionUiState,
onUpgrade: () -> Unit,
onRestore: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = CloserPalette.PurpleDeep,
modifier = Modifier.size(40.dp)
)
Spacer(Modifier.height(10.dp))
Text(
text = "Unlock Premium",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(4.dp))
Text(
text = "One subscription for both partners — no double billing.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = closerCardColor(alpha = 0.9f),
shadowElevation = 2.dp
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
BENEFITS.forEach { benefit ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = CloserPalette.PurpleDeep,
modifier = Modifier.size(16.dp)
)
Text(
text = benefit,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
Button(
onClick = onUpgrade,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = CloserPalette.PurpleDeep,
contentColor = Color.White
)
) {
Text("Upgrade to Premium", fontWeight = FontWeight.SemiBold)
}
TextButton(
onClick = onRestore,
enabled = !state.isRestoring,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (state.isRestoring) "Restoring…" else "Restore purchases",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(8.dp))
}
} }

View File

@ -300,8 +300,12 @@
- [ ] Support URL resolves correctly — now `https://closer.app/support`. - [ ] Support URL resolves correctly — now `https://closer.app/support`.
### 9.5 Subscription (`SubscriptionScreen`) ### 9.5 Subscription (`SubscriptionScreen`)
- [ ] Renders placeholder with paywall and settings actions.
- [ ] **Gap**: subscription management is placeholder; needs real UI before release. - [ ] **Free state**: star icon, "Unlock Premium" header, benefits list, Upgrade button navigates to `PAYWALL`, Restore link.
- [ ] **Premium state**: "You're Premium" card, renewal date (when available), benefits list, "Manage subscription" opens Play Store, Restore link.
- [ ] Restore purchases shows snackbar on success; error surfaces via snackbar.
- [ ] Reads entitlement reactively — upgrading mid-session reflects immediately without restart.
- [ ] **Note**: Requires real RevenueCat API key + active product to fully test both states.
### 9.6 Relationship settings / Delete account ### 9.6 Relationship settings / Delete account
- [ ] See pairing section for leave-couple flow. - [ ] See pairing section for leave-couple flow.
@ -388,7 +392,7 @@ These findings came from the static review and should be fixed before public or
| 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | **Not an issue**`DatePickerDialog` and `TimeInput` are already implemented and wired | | 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | **Not an issue**`DatePickerDialog` and `TimeInput` are already implemented and wired |
| 2 | Special dates | Hardcoded names/dates in `SpecialDatesSection` | High | **Not an issue**`SpecialDatesSection` is dead code, never rendered; home shows honest placeholder copy | | 2 | Special dates | Hardcoded names/dates in `SpecialDatesSection` | High | **Not an issue**`SpecialDatesSection` is dead code, never rendered; home shows honest placeholder copy |
| 3 | Email invite | Placeholder screen with hardcoded `ABC123` code | Medium | **Fixed** — screen deleted; share sheet is the flow | | 3 | Email invite | Placeholder screen with hardcoded `ABC123` code | Medium | **Fixed** — screen deleted; share sheet is the flow |
| 4 | Subscription | Placeholder screen, not real management | Medium | Open | | 4 | Subscription | Placeholder screen, not real management | Medium | **Fixed** — real `SubscriptionViewModel` reads `EntitlementChecker.isPremium()` + `CustomerInfo`; free state shows benefits + Upgrade button; premium state shows renewal date + "Manage subscription" → Play Store + Restore |
| 5 | Partner home | Placeholder screen only | Medium | **Fixed** — real `PartnerHomeViewModel` + screen with partner identity card, today activity status, send-nudge button, and navigation wired from HomeScreen streak card tap | | 5 | Partner home | Placeholder screen only | Medium | **Fixed** — real `PartnerHomeViewModel` + screen with partner identity card, today activity status, send-nudge button, and navigation wired from HomeScreen streak card tap |
| 6 | Settings | Account rows disabled ("Auth coming soon", "Export coming soon") | Medium | **Not an issue**`AccountScreen` no longer has those rows; only Delete account row present | | 6 | Settings | Account rows disabled ("Auth coming soon", "Export coming soon") | Medium | **Not an issue**`AccountScreen` no longer has those rows; only Delete account row present |
| 7 | External links | Support URL points to `couplesconnect.app/support` | Low | **Fixed** — updated to `https://closer.app/support` in `ExternalLinks.kt` | | 7 | External links | Support URL points to `couplesconnect.app/support` | Low | **Fixed** — updated to `https://closer.app/support` in `ExternalLinks.kt` |
@ -401,7 +405,7 @@ These findings came from the static review and should be fixed before public or
| 14 | Notifications | No push sent when partner answers or sends a chat message | Medium | **Fixed**`onAnswerWritten` gated on prefs; `onMessageWritten` CF added | | 14 | Notifications | No push sent when partner answers or sends a chat message | Medium | **Fixed**`onAnswerWritten` gated on prefs; `onMessageWritten` CF added |
| 15 | Notifications | Notification prefs were local-only; server CFs had no way to respect them | Medium | **Fixed** — prefs synced to Firestore user doc on toggle (Android + iOS) | | 15 | Notifications | Notification prefs were local-only; server CFs had no way to respect them | Medium | **Fixed** — prefs synced to Firestore user doc on toggle (Android + iOS) |
| 16 | Functions | `invite_attempts` subcollection had no cleanup — would grow forever | Medium | **Fixed**`expiresAt` TTL field added; `firestore.indexes.json` configures auto-delete | | 16 | Functions | `invite_attempts` subcollection had no cleanup — would grow forever | Medium | **Fixed**`expiresAt` TTL field added; `firestore.indexes.json` configures auto-delete |
| 17 | iOS deploy | `onMessageWritten` CF not yet deployed — iOS chat notifications not active until `firebase deploy --only functions` is run | Medium | Open — deploy required | | 17 | Deploy | `onMessageWritten` CF + `invite_attempts` TTL not yet live — run `firebase deploy --only functions && firebase deploy --only firestore:indexes` | Medium | **Pending** — code complete; deploy step requires Firebase CLI access from the project owner |
--- ---