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

This commit is contained in:
null 2026-06-22 11:14:19 -05:00
parent 7d5fc11366
commit d307904ff8
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.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.closerCardColor
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.filled.TrendingUp
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Favorite 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.LocalFireDepartment
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd
import app.closer.domain.model.OutcomeDay import app.closer.domain.model.OutcomeDay
import app.closer.ui.components.OutcomeCheckInDialog import app.closer.ui.components.OutcomeCheckInDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -167,6 +171,7 @@ fun HomeScreen(
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) }, onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onSettings = { onNavigate(AppRoute.SETTINGS) }, onSettings = { onNavigate(AppRoute.SETTINGS) },
onInvite = { onNavigate(AppRoute.CREATE_INVITE) }, onInvite = { onNavigate(AppRoute.CREATE_INVITE) },
onAcceptInvite = { onNavigate(AppRoute.ACCEPT_INVITE) },
onReminder = viewModel::sendGentleReminder, onReminder = viewModel::sendGentleReminder,
onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } }, onReveal = { state.dailyQuestion?.id?.let { onNavigate(AppRoute.answerReveal(it)) } },
onFollowUp = { state.dailyQuestion?.let { onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id)) } }, onFollowUp = { state.dailyQuestion?.let { onNavigate(AppRoute.questionThread(state.coupleId ?: "", it.id)) } },
@ -185,6 +190,7 @@ data class HomeCallbacks(
val onHistory: () -> Unit, val onHistory: () -> Unit,
val onSettings: () -> Unit, val onSettings: () -> Unit,
val onInvite: () -> Unit, val onInvite: () -> Unit,
val onAcceptInvite: () -> Unit,
val onRefresh: () -> Unit val onRefresh: () -> Unit
) )
@ -214,6 +220,7 @@ private fun HomeContent(
onHistory: () -> Unit, onHistory: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onInvite: () -> Unit, onInvite: () -> Unit,
onAcceptInvite: () -> Unit,
onReminder: () -> Unit, onReminder: () -> Unit,
onReveal: () -> Unit, onReveal: () -> Unit,
onFollowUp: () -> Unit, onFollowUp: () -> Unit,
@ -222,7 +229,7 @@ private fun HomeContent(
) { ) {
val callbacks = remember( val callbacks = remember(
onDailyQuestion, onReminder, onReveal, onFollowUp, onDailyQuestion, onReminder, onReveal, onFollowUp,
onPacks, onCategory, onHistory, onSettings, onInvite, onRefresh onPacks, onCategory, onHistory, onSettings, onInvite, onAcceptInvite, onRefresh
) { ) {
HomeCallbacks( HomeCallbacks(
onDailyQuestion = onDailyQuestion, onDailyQuestion = onDailyQuestion,
@ -234,6 +241,7 @@ private fun HomeContent(
onHistory = onHistory, onHistory = onHistory,
onSettings = onSettings, onSettings = onSettings,
onInvite = onInvite, onInvite = onInvite,
onAcceptInvite = onAcceptInvite,
onRefresh = onRefresh onRefresh = onRefresh
) )
} }
@ -274,14 +282,17 @@ private fun HomeContent(
) { ) {
HomeHeader( HomeHeader(
partnerName = state.partnerName, partnerName = state.partnerName,
streakCount = state.streakCount streakCount = state.streakCount,
isPaired = state.isPaired
) )
StreakCard( if (state.isPaired) {
streakCount = state.streakCount, StreakCard(
partnerName = state.partnerName, streakCount = state.streakCount,
onPartner = onPartner partnerName = state.partnerName,
) onPartner = onPartner
)
}
when { when {
state.isLoading -> LoadingHomeCard() state.isLoading -> LoadingHomeCard()
@ -295,17 +306,24 @@ private fun HomeContent(
} }
state.primaryAction?.let { action -> state.primaryAction?.let { action ->
PrimaryHomeActionCard( if (!state.isPaired && action.target == HomeActionTarget.InvitePartner) {
action = action, PartnerActivationCard(
stats = state.answerStats, onInvite = callbacks.onInvite,
streakCount = state.streakCount, onAcceptInvite = callbacks.onAcceptInvite
onAction = onActionSelected, )
onReminder = callbacks.onReminder, } else {
onReveal = callbacks.onReveal, PrimaryHomeActionCard(
onFollowUp = callbacks.onFollowUp, action = action,
dailyQuestionState = state.dailyQuestionState, stats = state.answerStats,
dailyQuestion = state.dailyQuestion streakCount = state.streakCount,
) onAction = onActionSelected,
onReminder = callbacks.onReminder,
onReveal = callbacks.onReveal,
onFollowUp = callbacks.onFollowUp,
dailyQuestionState = state.dailyQuestionState,
dailyQuestion = state.dailyQuestion
)
}
} }
ActionFeedSection( ActionFeedSection(
@ -412,7 +430,8 @@ private fun StreakCard(
@Composable @Composable
private fun HomeHeader( private fun HomeHeader(
partnerName: String?, partnerName: String?,
streakCount: Int streakCount: Int,
isPaired: Boolean
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row( Row(
@ -431,7 +450,9 @@ private fun HomeHeader(
} }
} }
Text( 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." "Connected with $partnerName. One clear next step, then the rest can stay quiet."
else else
"Open the app, see what matters, and take one small step toward closeness.", "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 @Composable
private fun PrimaryHomeActionCard( private fun PrimaryHomeActionCard(
action: HomeAction, action: HomeAction,
@ -1125,6 +1322,7 @@ fun HomeScreenPreview() {
onHistory = {}, onHistory = {},
onSettings = {}, onSettings = {},
onInvite = {}, onInvite = {},
onAcceptInvite = {},
onRefresh = {} onRefresh = {}
) )
} }

View File

@ -549,9 +549,9 @@ class HomeViewModel @Inject constructor(
) else null ) else null
Priority.PAIRING_NEEDED -> HomeAction( Priority.PAIRING_NEEDED -> HomeAction(
eyebrow = "Next best action", eyebrow = "1 of 2 connected",
title = "Invite your partner into tonight.", title = "A private space for two",
body = "The app works best as a shared ritual. Send a private invite and make the next prompt something you can both answer.", body = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.",
cta = "Invite partner", cta = "Invite partner",
target = HomeActionTarget.InvitePartner, target = HomeActionTarget.InvitePartner,
tone = HomeActionTone.Invite tone = HomeActionTone.Invite

View File

@ -6,6 +6,8 @@ struct HomeView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@State private var showPartnerHome = false @State private var showPartnerHome = false
@State private var showBucketList = false @State private var showBucketList = false
@State private var showCreateInvite = false
@State private var showAcceptInvite = false
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -22,9 +24,17 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.closerPadding() .closerPadding()
// Streak card if appState.currentCouple == nil {
StreakCard() PartnerActivationCard(
onInvite: { showCreateInvite = true },
onAcceptInvite: { showAcceptInvite = true }
)
.closerPadding() .closerPadding()
} else {
// Streak card
StreakCard()
.closerPadding()
}
// Quick actions // Quick actions
VStack(alignment: .leading, spacing: CloserSpacing.md) { VStack(alignment: .leading, spacing: CloserSpacing.md) {
@ -59,41 +69,43 @@ struct HomeView: View {
} }
} }
// Partner status if appState.currentCouple != nil {
VStack(alignment: .leading, spacing: CloserSpacing.sm) { // Partner status
Text("Partner") VStack(alignment: .leading, spacing: CloserSpacing.sm) {
.closerSectionTitle() Text("Partner")
.closerSectionTitle()
.closerPadding()
PartnerStatusRow(
displayName: appState.currentPartner?.displayName ?? "Your Partner",
answered: false,
lastActive: nil
)
.closerPadding() .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() .closerPadding()
} }
// Bottom nav to partner home
Button(action: { showPartnerHome = true }) {
Label("View Partner's Activity", systemImage: "person.fill")
}
.buttonStyle(SecondaryButtonStyle())
.closerPadding()
Spacer() Spacer()
.frame(height: CloserSpacing.xxl) .frame(height: CloserSpacing.xxl)
} }
@ -106,6 +118,12 @@ struct HomeView: View {
.navigationDestination(isPresented: $showBucketList) { .navigationDestination(isPresented: $showBucketList) {
BucketListView() BucketListView()
} }
.navigationDestination(isPresented: $showCreateInvite) {
CreateInviteView()
}
.navigationDestination(isPresented: $showAcceptInvite) {
AcceptInviteView()
}
} }
} }
@ -223,4 +241,4 @@ struct StatRow: View {
.background(Color.closerSurface) .background(Color.closerSurface)
.cornerRadius(CloserRadius.medium) .cornerRadius(CloserRadius.medium)
} }
} }

View File

@ -8,36 +8,35 @@ struct PairPromptView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: CloserSpacing.xxl) { ScrollView {
Spacer() 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) PartnerActivationCard(
onInvite: { showCreateInvite = true },
Text("Connect with Your Partner") onAcceptInvite: { showAcceptInvite = true }
.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)
.closerPadding() .closerPadding()
VStack(spacing: CloserSpacing.lg) { Text("You can keep exploring Closer, but shared reveals and partner activity start after your person joins.")
Button(action: { showCreateInvite = true }) { .font(CloserFont.footnote)
Label("Invite my partner", systemImage: "plus.circle.fill") .foregroundColor(.closerTextSecondary)
} .multilineTextAlignment(.center)
.buttonStyle(PrimaryButtonStyle()) .closerPadding()
Button(action: { showAcceptInvite = true }) { Spacer()
Label("Enter a code", systemImage: "key.fill") .frame(height: CloserSpacing.xxl)
}
.buttonStyle(SecondaryButtonStyle())
} }
.closerPadding()
Spacer()
} }
.background(Color.closerBackground) .background(Color.closerBackground)
.navigationDestination(isPresented: $showCreateInvite) { .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 // MARK: - Create Invite
struct CreateInviteView: View { struct CreateInviteView: View {