feat: subscription screen with clear pricing, restore, cancel instructions
This commit is contained in:
parent
dff86eb089
commit
62d99505c9
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue