feat: home screen + viewmodel, iOS home & pairing views

This commit is contained in:
null 2026-06-22 11:14:19 -05:00
parent 5ac4f40bf6
commit af35ec029b
4 changed files with 431 additions and 82 deletions

View File

@ -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 = {}
)
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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 {