diff --git a/app/src/main/java/app/closer/core/navigation/ExternalLinks.kt b/app/src/main/java/app/closer/core/navigation/ExternalLinks.kt index fc017135..fc6d129b 100644 --- a/app/src/main/java/app/closer/core/navigation/ExternalLinks.kt +++ b/app/src/main/java/app/closer/core/navigation/ExternalLinks.kt @@ -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)) diff --git a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt index 1ab0f826..d1dea4ce 100644 --- a/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SubscriptionScreen.kt @@ -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 = _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)) + } } diff --git a/docs/qa/private-mvp-checklist.md b/docs/qa/private-mvp-checklist.md index 690eccc2..5a6c9e1c 100644 --- a/docs/qa/private-mvp-checklist.md +++ b/docs/qa/private-mvp-checklist.md @@ -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 | ---