feat: subscription screen with clear pricing, restore, cancel instructions
This commit is contained in:
parent
8ed8061fb4
commit
768536861a
|
|
@ -13,6 +13,7 @@ object ExternalLinks {
|
|||
// TODO: Update placeholder URL before production.
|
||||
const val SUBSCRIPTION_TERMS = "https://closer.app/subscription-terms"
|
||||
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) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
|
|
|
|||
|
|
@ -1,35 +1,429 @@
|
|||
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.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.ui.components.FinishedEmptyStateAction
|
||||
import app.closer.ui.components.FinishedEmptyStateScreen
|
||||
import app.closer.core.navigation.ExternalLinks
|
||||
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.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
|
||||
fun SubscriptionScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: SubscriptionViewModel = hiltViewModel()
|
||||
) {
|
||||
FinishedEmptyStateScreen(
|
||||
eyebrow = "Subscription",
|
||||
title = "Manage access from the paywall",
|
||||
body = "Plan details, restore purchases, and upgrades live together so billing choices stay clear.",
|
||||
glyphCategoryId = "star",
|
||||
primaryAction = FinishedEmptyStateAction("Open paywall", AppRoute.PAYWALL),
|
||||
secondaryAction = FinishedEmptyStateAction("Back to settings", AppRoute.SETTINGS),
|
||||
accent = CloserPalette.PurpleDeep,
|
||||
details = listOf(
|
||||
"Review the upgrade path before making a choice.",
|
||||
"Restore purchase access from the same flow.",
|
||||
"Keep account settings separate from billing decisions."
|
||||
),
|
||||
onNavigate = onNavigate
|
||||
)
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.restoreSuccess) {
|
||||
if (state.restoreSuccess) {
|
||||
snackbar.showSnackbar("Purchases restored.")
|
||||
viewModel.consumeRestoreSuccess()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.restoreError) {
|
||||
state.restoreError?.let {
|
||||
snackbar.showSnackbar(it)
|
||||
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
|
||||
fun SubscriptionScreenPreview() {
|
||||
SubscriptionScreen()
|
||||
private fun PremiumContent(
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,8 +300,12 @@
|
|||
- [ ] Support URL resolves correctly — now `https://closer.app/support`.
|
||||
|
||||
### 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
|
||||
- [ ] 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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` |
|
||||
|
|
@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue