From af35ec029b7db5b3605e8b4c3a5270f719f6fd97 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 11:14:19 -0500 Subject: [PATCH] feat: home screen + viewmodel, iOS home & pairing views --- .../java/app/closer/ui/home/HomeScreen.kt | 238 ++++++++++++++++-- .../java/app/closer/ui/home/HomeViewModel.kt | 6 +- iphone/Closer/Home/HomeViews.swift | 86 ++++--- iphone/Closer/Pairing/PairingViews.swift | 183 ++++++++++++-- 4 files changed, 431 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 5a774300..7b19c6d4 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -17,6 +17,7 @@ import app.closer.ui.questions.displayCategoryName import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerCardColor +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,7 +36,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.TrendingUp import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd import app.closer.domain.model.OutcomeDay import app.closer.ui.components.OutcomeCheckInDialog import androidx.compose.material3.Icon @@ -167,6 +171,7 @@ fun HomeScreen( onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) }, onSettings = { onNavigate(AppRoute.SETTINGS) }, onInvite = { onNavigate(AppRoute.CREATE_INVITE) }, + onAcceptInvite = { onNavigate(AppRoute.ACCEPT_INVITE) }, onReminder = viewModel::sendGentleReminder, onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } }, onFollowUp = { state.dailyQuestion?.let { onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id)) } }, @@ -185,6 +190,7 @@ data class HomeCallbacks( val onHistory: () -> Unit, val onSettings: () -> Unit, val onInvite: () -> Unit, + val onAcceptInvite: () -> Unit, val onRefresh: () -> Unit ) @@ -214,6 +220,7 @@ private fun HomeContent( onHistory: () -> Unit, onSettings: () -> Unit, onInvite: () -> Unit, + onAcceptInvite: () -> Unit, onReminder: () -> Unit, onReveal: () -> Unit, onFollowUp: () -> Unit, @@ -222,7 +229,7 @@ private fun HomeContent( ) { val callbacks = remember( onDailyQuestion, onReminder, onReveal, onFollowUp, - onPacks, onCategory, onHistory, onSettings, onInvite, onRefresh + onPacks, onCategory, onHistory, onSettings, onInvite, onAcceptInvite, onRefresh ) { HomeCallbacks( onDailyQuestion = onDailyQuestion, @@ -234,6 +241,7 @@ private fun HomeContent( onHistory = onHistory, onSettings = onSettings, onInvite = onInvite, + onAcceptInvite = onAcceptInvite, onRefresh = onRefresh ) } @@ -274,14 +282,17 @@ private fun HomeContent( ) { HomeHeader( partnerName = state.partnerName, - streakCount = state.streakCount + streakCount = state.streakCount, + isPaired = state.isPaired ) - StreakCard( - streakCount = state.streakCount, - partnerName = state.partnerName, - onPartner = onPartner - ) + if (state.isPaired) { + StreakCard( + streakCount = state.streakCount, + partnerName = state.partnerName, + onPartner = onPartner + ) + } when { state.isLoading -> LoadingHomeCard() @@ -295,17 +306,24 @@ private fun HomeContent( } state.primaryAction?.let { action -> - PrimaryHomeActionCard( - action = action, - stats = state.answerStats, - streakCount = state.streakCount, - onAction = onActionSelected, - onReminder = callbacks.onReminder, - onReveal = callbacks.onReveal, - onFollowUp = callbacks.onFollowUp, - dailyQuestionState = state.dailyQuestionState, - dailyQuestion = state.dailyQuestion - ) + if (!state.isPaired && action.target == HomeActionTarget.InvitePartner) { + PartnerActivationCard( + onInvite = callbacks.onInvite, + onAcceptInvite = callbacks.onAcceptInvite + ) + } else { + PrimaryHomeActionCard( + action = action, + stats = state.answerStats, + streakCount = state.streakCount, + onAction = onActionSelected, + onReminder = callbacks.onReminder, + onReveal = callbacks.onReveal, + onFollowUp = callbacks.onFollowUp, + dailyQuestionState = state.dailyQuestionState, + dailyQuestion = state.dailyQuestion + ) + } } ActionFeedSection( @@ -412,7 +430,8 @@ private fun StreakCard( @Composable private fun HomeHeader( partnerName: String?, - streakCount: Int + streakCount: Int, + isPaired: Boolean ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row( @@ -431,7 +450,9 @@ private fun HomeHeader( } } Text( - text = if (partnerName != null) + text = if (!isPaired) + "Set up your shared space, then keep exploring at your own pace." + else if (partnerName != null) "Connected with $partnerName. One clear next step, then the rest can stay quiet." else "Open the app, see what matters, and take one small step toward closeness.", @@ -443,6 +464,182 @@ private fun HomeHeader( } } +@Composable +private fun PartnerActivationCard( + onInvite: () -> Unit, + onAcceptInvite: () -> Unit +) { + CloserCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(CloserRadii.FeatureCard), + containerColor = closerCardColor(alpha = 0.94f), + elevation = CloserElevations.Feature + ) { + Column( + modifier = Modifier + .background( + Brush.linearGradient( + listOf( + MaterialTheme.colorScheme.surface, + CloserPalette.PurpleSoft, + CloserPalette.PinkMist.copy(alpha = 0.84f) + ), + start = Offset.Zero, + end = Offset.Infinite + ) + ) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + HomePill("1 of 2 connected") + Surface( + shape = RoundedCornerShape(CloserRadii.Pill), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.76f) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = CloserPalette.PurpleDeep, + modifier = Modifier.size(14.dp) + ) + Text( + text = "Private invite", + style = MaterialTheme.typography.labelSmall, + color = CloserPalette.PurpleDeep, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PartnerActivationAvatars() + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "A private space for two", + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF4D4354), + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ActivationBenefitPill("Private reveals", Modifier.weight(1f)) + ActivationBenefitPill("Shared streak", Modifier.weight(1f)) + ActivationBenefitPill("Games for two", Modifier.weight(1f)) + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + CloserActionButton( + label = "Invite partner", + onClick = onInvite, + modifier = Modifier.fillMaxWidth(), + containerColor = CloserPalette.PurpleDeep, + contentColor = MaterialTheme.colorScheme.surface + ) + CloserActionButton( + label = "Enter code", + onClick = onAcceptInvite, + modifier = Modifier.fillMaxWidth(), + style = CloserButtonStyle.Secondary, + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + contentColor = CloserPalette.PurpleDeep + ) + } + } + } +} + +@Composable +private fun PartnerActivationAvatars() { + Box(modifier = Modifier.size(width = 92.dp, height = 58.dp)) { + Surface( + modifier = Modifier + .size(52.dp) + .align(Alignment.CenterStart), + shape = RoundedCornerShape(999.dp), + color = CloserPalette.PurpleDeep + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.surface, + modifier = Modifier.size(27.dp) + ) + } + } + Surface( + modifier = Modifier + .size(52.dp) + .align(Alignment.CenterEnd), + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + border = BorderStroke(1.5.dp, CloserPalette.PurpleDeep.copy(alpha = 0.42f)) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.PersonAdd, + contentDescription = null, + tint = CloserPalette.PurpleDeep, + modifier = Modifier.size(26.dp) + ) + } + } + } +} + +@Composable +private fun ActivationBenefitPill( + label: String, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(CloserRadii.Pill), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.66f) + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 7.dp), + style = MaterialTheme.typography.labelSmall, + color = CloserPalette.PurpleDeep, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + @Composable private fun PrimaryHomeActionCard( action: HomeAction, @@ -1125,6 +1322,7 @@ fun HomeScreenPreview() { onHistory = {}, onSettings = {}, onInvite = {}, + onAcceptInvite = {}, onRefresh = {} ) } diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index dbf93af5..038b59de 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -549,9 +549,9 @@ class HomeViewModel @Inject constructor( ) else null Priority.PAIRING_NEEDED -> HomeAction( - eyebrow = "Next best action", - title = "Invite your partner into tonight.", - body = "The app works best as a shared ritual. Send a private invite and make the next prompt something you can both answer.", + eyebrow = "1 of 2 connected", + title = "A private space for two", + body = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.", cta = "Invite partner", target = HomeActionTarget.InvitePartner, tone = HomeActionTone.Invite diff --git a/iphone/Closer/Home/HomeViews.swift b/iphone/Closer/Home/HomeViews.swift index 75fac76e..8db58aff 100644 --- a/iphone/Closer/Home/HomeViews.swift +++ b/iphone/Closer/Home/HomeViews.swift @@ -6,6 +6,8 @@ struct HomeView: View { @EnvironmentObject var appState: AppState @State private var showPartnerHome = false @State private var showBucketList = false + @State private var showCreateInvite = false + @State private var showAcceptInvite = false var body: some View { ScrollView { @@ -22,9 +24,17 @@ struct HomeView: View { .frame(maxWidth: .infinity, alignment: .leading) .closerPadding() - // Streak card - StreakCard() + if appState.currentCouple == nil { + PartnerActivationCard( + onInvite: { showCreateInvite = true }, + onAcceptInvite: { showAcceptInvite = true } + ) .closerPadding() + } else { + // Streak card + StreakCard() + .closerPadding() + } // Quick actions VStack(alignment: .leading, spacing: CloserSpacing.md) { @@ -59,41 +69,43 @@ struct HomeView: View { } } - // Partner status - VStack(alignment: .leading, spacing: CloserSpacing.sm) { - Text("Partner") - .closerSectionTitle() + if appState.currentCouple != nil { + // Partner status + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + Text("Partner") + .closerSectionTitle() + .closerPadding() + + PartnerStatusRow( + displayName: appState.currentPartner?.displayName ?? "Your Partner", + answered: false, + lastActive: nil + ) .closerPadding() - - PartnerStatusRow( - displayName: "Your Partner", - answered: false, - lastActive: nil - ) - .closerPadding() - } - - // Relationship summary - VStack(alignment: .leading, spacing: CloserSpacing.sm) { - Text("Your Journey") - .closerSectionTitle() - .closerPadding() - - VStack(spacing: CloserSpacing.sm) { - StatRow(icon: "flame.fill", label: "Streak", value: "\(appState.currentCouple?.streakCount ?? 0) days") - StatRow(icon: "questionmark.bubble.fill", label: "Questions Answered", value: "12") - StatRow(icon: "gamecontroller.fill", label: "Games Played", value: "5") } + + // Relationship summary + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + Text("Your Journey") + .closerSectionTitle() + .closerPadding() + + VStack(spacing: CloserSpacing.sm) { + StatRow(icon: "flame.fill", label: "Streak", value: "\(appState.currentCouple?.streakCount ?? 0) days") + StatRow(icon: "questionmark.bubble.fill", label: "Questions Answered", value: "12") + StatRow(icon: "gamecontroller.fill", label: "Games Played", value: "5") + } + .closerPadding() + } + + // Bottom nav to partner home + Button(action: { showPartnerHome = true }) { + Label("View Partner's Activity", systemImage: "person.fill") + } + .buttonStyle(SecondaryButtonStyle()) .closerPadding() } - - // Bottom nav to partner home - Button(action: { showPartnerHome = true }) { - Label("View Partner's Activity", systemImage: "person.fill") - } - .buttonStyle(SecondaryButtonStyle()) - .closerPadding() - + Spacer() .frame(height: CloserSpacing.xxl) } @@ -106,6 +118,12 @@ struct HomeView: View { .navigationDestination(isPresented: $showBucketList) { BucketListView() } + .navigationDestination(isPresented: $showCreateInvite) { + CreateInviteView() + } + .navigationDestination(isPresented: $showAcceptInvite) { + AcceptInviteView() + } } } @@ -223,4 +241,4 @@ struct StatRow: View { .background(Color.closerSurface) .cornerRadius(CloserRadius.medium) } -} \ No newline at end of file +} diff --git a/iphone/Closer/Pairing/PairingViews.swift b/iphone/Closer/Pairing/PairingViews.swift index 276fb9b3..ca67d1fe 100644 --- a/iphone/Closer/Pairing/PairingViews.swift +++ b/iphone/Closer/Pairing/PairingViews.swift @@ -8,36 +8,35 @@ struct PairPromptView: View { var body: some View { NavigationStack { - VStack(spacing: CloserSpacing.xxl) { - Spacer() + ScrollView { + VStack(spacing: CloserSpacing.xl) { + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + Text("You're in") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + Text("Set up your shared space") + .font(CloserFont.title1) + .foregroundColor(.closerText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .closerPadding() + .padding(.top, CloserSpacing.xl) - CloserIllustrationView(imageName: "illustration-couple-invite", size: 190) - - Text("Connect with Your Partner") - .font(CloserFont.title1) - .foregroundColor(.closerText) - .multilineTextAlignment(.center) - - Text("Create an invite code for your partner to join, or enter their code to connect.") - .font(CloserFont.callout) - .foregroundColor(.closerTextSecondary) - .multilineTextAlignment(.center) + PartnerActivationCard( + onInvite: { showCreateInvite = true }, + onAcceptInvite: { showAcceptInvite = true } + ) .closerPadding() - VStack(spacing: CloserSpacing.lg) { - Button(action: { showCreateInvite = true }) { - Label("Invite my partner", systemImage: "plus.circle.fill") - } - .buttonStyle(PrimaryButtonStyle()) + Text("You can keep exploring Closer, but shared reveals and partner activity start after your person joins.") + .font(CloserFont.footnote) + .foregroundColor(.closerTextSecondary) + .multilineTextAlignment(.center) + .closerPadding() - Button(action: { showAcceptInvite = true }) { - Label("Enter a code", systemImage: "key.fill") - } - .buttonStyle(SecondaryButtonStyle()) + Spacer() + .frame(height: CloserSpacing.xxl) } - .closerPadding() - - Spacer() } .background(Color.closerBackground) .navigationDestination(isPresented: $showCreateInvite) { @@ -50,6 +49,140 @@ struct PairPromptView: View { } } +// MARK: - Partner Activation + +struct PartnerActivationCard: View { + let onInvite: () -> Void + let onAcceptInvite: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: CloserSpacing.lg) { + HStack { + Text("1 of 2 connected") + .font(CloserFont.caption) + .fontWeight(.semibold) + .foregroundColor(.closerPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.closerBackground.opacity(0.75)) + .clipShape(Capsule()) + + Spacer() + + Label("Private invite", systemImage: "lock.fill") + .font(CloserFont.caption) + .fontWeight(.semibold) + .foregroundColor(.closerPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.closerBackground.opacity(0.75)) + .clipShape(Capsule()) + } + + HStack(alignment: .center, spacing: CloserSpacing.lg) { + ActivationAvatarPair() + + VStack(alignment: .leading, spacing: CloserSpacing.sm) { + Text("A private space for two") + .font(CloserFont.title2) + .foregroundColor(.closerText) + .fixedSize(horizontal: false, vertical: true) + + Text("Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + HStack(spacing: CloserSpacing.sm) { + ActivationBenefitChip("Private reveals") + ActivationBenefitChip("Shared streak") + ActivationBenefitChip("Games for two") + } + + VStack(spacing: CloserSpacing.md) { + Button(action: onInvite) { + Label("Invite partner", systemImage: "person.crop.circle.badge.plus") + } + .buttonStyle(PrimaryButtonStyle()) + + Button(action: onAcceptInvite) { + Label("Enter code", systemImage: "key.fill") + } + .buttonStyle(SecondaryButtonStyle()) + } + } + .padding(CloserSpacing.lg) + .background( + LinearGradient( + colors: [ + Color.closerSurface, + Color.closerPrimary.opacity(0.18), + Color.closerSecondary.opacity(0.18) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: CloserRadius.xlarge, style: .continuous)) + .closerShadow(level: .medium) + } +} + +private struct ActivationAvatarPair: View { + var body: some View { + ZStack { + Circle() + .fill(Color.closerPrimary) + .frame(width: 54, height: 54) + .overlay { + Image(systemName: "person.fill") + .font(.system(size: 25, weight: .semibold)) + .foregroundColor(.white) + } + .offset(x: -18) + + Circle() + .fill(Color.closerBackground.opacity(0.9)) + .frame(width: 54, height: 54) + .overlay { + Circle() + .stroke(Color.closerPrimary.opacity(0.42), lineWidth: 1.5) + } + .overlay { + Image(systemName: "person.crop.circle.badge.plus") + .font(.system(size: 23, weight: .semibold)) + .foregroundColor(.closerPrimary) + } + .offset(x: 18) + } + .frame(width: 92, height: 62) + } +} + +private struct ActivationBenefitChip: View { + let label: String + + init(_ label: String) { + self.label = label + } + + var body: some View { + Text(label) + .font(CloserFont.caption2) + .fontWeight(.semibold) + .foregroundColor(.closerPrimary) + .lineLimit(1) + .minimumScaleFactor(0.78) + .frame(maxWidth: .infinity) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .background(Color.closerBackground.opacity(0.68)) + .clipShape(Capsule()) + } +} + // MARK: - Create Invite struct CreateInviteView: View {