feat: navigation, answer history screen + viewmodel, answer reveal, iOS navigation & question views

This commit is contained in:
null 2026-06-22 19:44:44 -05:00
parent 125a24eb85
commit 7db075d195
6 changed files with 153 additions and 32 deletions

View File

@ -497,6 +497,7 @@ private val shellBackRoutes = setOf(
AppRoute.QUESTION_COMPOSER,
AppRoute.QUESTION_THREAD,
AppRoute.ANSWER_REVEAL,
AppRoute.ANSWER_HISTORY,
AppRoute.CATEGORY_PICKER,
AppRoute.SPIN_WHEEL,
AppRoute.WHEEL_SESSION,

View File

@ -70,8 +70,16 @@ fun AnswerHistoryScreen(
AnswerHistoryContent(
state = state,
onAnswerSelected = { onNavigate(AppRoute.answerReveal(it.questionId)) },
onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) },
onAnswerSelected = {
if (state.isPairingLoaded) {
onNavigate(if (state.isPaired) AppRoute.answerReveal(it.questionId) else AppRoute.CREATE_INVITE)
}
},
onEmptyAction = {
if (state.isPairingLoaded) {
onNavigate(if (state.isPaired) AppRoute.DAILY_QUESTION else AppRoute.CREATE_INVITE)
}
},
onDelete = viewModel::deleteAnswer
)
}
@ -80,7 +88,7 @@ fun AnswerHistoryScreen(
private fun AnswerHistoryContent(
state: AnswerHistoryUiState,
onAnswerSelected: (LocalAnswer) -> Unit,
onDailyQuestion: () -> Unit,
onEmptyAction: () -> Unit,
onDelete: (String) -> Unit
) {
var pendingDelete by remember { mutableStateOf<LocalAnswer?>(null) }
@ -159,11 +167,31 @@ private fun AnswerHistoryContent(
if (state.answers.isEmpty()) {
item {
val title = when {
!state.isPairingLoaded -> "Checking your space"
state.isPaired -> "Nothing here yet"
else -> "Connect your partner"
}
val body = when {
!state.isPairingLoaded -> "We are checking whether this private space is connected."
state.isPaired -> "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here."
else -> "Invite your partner to unlock shared reveals and build your private answer history together."
}
val actionLabel = when {
!state.isPairingLoaded -> null
state.isPaired -> "Today's question"
else -> "Invite partner"
}
val onAction = if (state.isPairingLoaded) {
onEmptyAction
} else {
null
}
EmptyState(
title = "Nothing here yet",
body = "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here.",
actionLabel = "Today's question",
onAction = onDailyQuestion,
title = title,
body = body,
actionLabel = actionLabel,
onAction = onAction,
illustrationResId = R.drawable.illustration_couple_history
)
}
@ -399,7 +427,7 @@ fun AnswerHistoryScreenPreview() {
)
),
onAnswerSelected = {},
onDailyQuestion = {},
onEmptyAction = {},
onDelete = {}
)
}

View File

@ -3,36 +3,52 @@ package app.closer.ui.answers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.model.LocalAnswer
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.LocalAnswerRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class AnswerHistoryUiState(
val answers: List<LocalAnswer> = emptyList()
val answers: List<LocalAnswer> = emptyList(),
val isPaired: Boolean = false,
val isPairingLoaded: Boolean = false
)
@HiltViewModel
class AnswerHistoryViewModel @Inject constructor(
private val localAnswerRepository: LocalAnswerRepository
private val localAnswerRepository: LocalAnswerRepository,
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AnswerHistoryUiState())
val uiState: StateFlow<AnswerHistoryUiState> = _uiState.asStateFlow()
init {
loadPairingState()
viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers ->
_uiState.value = AnswerHistoryUiState(
answers = answers.sortedByDescending { it.updatedAt }
)
_uiState.update { it.copy(answers = answers.sortedByDescending { answer -> answer.updatedAt }) }
}
}
}
private fun loadPairingState() {
viewModelScope.launch {
val userId = authRepository.currentUserId
val isPaired = userId?.let {
runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() != null
} ?: false
_uiState.update { it.copy(isPaired = isPaired, isPairingLoaded = true) }
}
}
fun deleteAnswer(questionId: String) {
viewModelScope.launch {
localAnswerRepository.deleteAnswer(questionId)

View File

@ -64,6 +64,12 @@ fun AnswerRevealScreen(
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(state.isLoading, state.coupleId) {
if (!state.isLoading && state.coupleId == null) {
onNavigate(AppRoute.CREATE_INVITE)
}
}
AnswerRevealContent(
state = state,
questionId = questionId,

View File

@ -46,6 +46,8 @@ struct ContentView: View {
struct MainTabView: View {
@EnvironmentObject var appState: AppState
@State private var selectedTab: Tab = .home
@State private var showCreateInvite = false
@State private var showAnswerHistory = false
enum Tab: Hashable {
case home, dailyQuestion, play, questionPacks, settings
@ -85,10 +87,10 @@ struct MainTabView: View {
}
.tint(.closerPrimary)
.onReceive(NotificationCenter.default.publisher(for: .navigateToDailyQuestion)) { _ in
selectedTab = .dailyQuestion
routeToDailyOrInvite()
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToReveal)) { _ in
selectedTab = .dailyQuestion
routeToDailyOrInvite()
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
selectedTab = .home
@ -103,11 +105,30 @@ struct MainTabView: View {
selectedTab = .play
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToAnswerHistory)) { _ in
selectedTab = .dailyQuestion
if appState.currentUser?.coupleId == nil {
showCreateInvite = true
} else {
showAnswerHistory = true
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToSettings)) { _ in
selectedTab = .settings
}
.navigationDestination(isPresented: $showAnswerHistory) {
AnswerHistoryView()
}
.fullScreenCover(isPresented: $showCreateInvite) {
CreateInviteView()
.environmentObject(appState)
}
}
private func routeToDailyOrInvite() {
if appState.currentUser?.coupleId == nil {
showCreateInvite = true
} else {
selectedTab = .dailyQuestion
}
}
}
@ -130,4 +151,4 @@ struct OnboardingFlow: View {
// MARK: - Import for Secret
import Foundation
import Foundation

View File

@ -293,10 +293,22 @@ struct AnswerRevealView: View {
@EnvironmentObject var appState: AppState
@State private var partnerAnswer: String?
@State private var isLoading = true
@State private var showCreateInvite = false
private var isPaired: Bool {
appState.currentUser?.coupleId != nil
}
var body: some View {
VStack(spacing: CloserSpacing.xxl) {
if isLoading {
if !isPaired {
EmptyStateView(
icon: "lock.fill",
title: "Connect your partner",
message: "Invite your partner to unlock shared reveals and private answers you can open together.",
action: (title: "Invite partner", handler: { showCreateInvite = true })
)
} else if isLoading {
LoadingView(message: "Loading answer...")
} else if let answer = partnerAnswer {
Image(systemName: "heart.fill")
@ -329,6 +341,10 @@ struct AnswerRevealView: View {
.closerPadding()
.background(Color.closerBackground)
.navigationBarTitleDisplayMode(.inline)
.fullScreenCover(isPresented: $showCreateInvite) {
CreateInviteView()
.environmentObject(appState)
}
.task {
// Load partner's answer
try? await Task.sleep(nanoseconds: 800_000_000)
@ -340,8 +356,18 @@ struct AnswerRevealView: View {
// MARK: - Answer History
struct AnswerHistoryView: View {
@EnvironmentObject var appState: AppState
@State private var answers: [Answer] = []
@State private var isLoading = true
@State private var showCreateInvite = false
private var isPaired: Bool {
appState.currentUser?.coupleId != nil
}
private var emptyAction: (title: String, handler: () -> Void)? {
isPaired ? nil : (title: "Invite partner", handler: { showCreateInvite = true })
}
var body: some View {
List {
@ -351,25 +377,28 @@ struct AnswerHistoryView: View {
} else if answers.isEmpty {
EmptyStateView(
icon: "clock.arrow.circlepath",
title: "No Answers Yet",
message: "Your answer history will appear here.",
illustrationName: "illustration-couple-history"
title: isPaired ? "No Answers Yet" : "Connect your partner",
message: isPaired
? "Your answer history will appear here."
: "Invite your partner to unlock shared reveals and build your private answer history together.",
illustrationName: "illustration-couple-history",
action: emptyAction
)
.listRowBackground(Color.clear)
} else {
ForEach(answers) { answer in
NavigationLink {
AnswerRevealView(questionId: answer.questionId)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(answer.answerText)
.font(CloserFont.body)
.foregroundColor(.closerText)
Text(answer.createdAt, style: .date)
.font(CloserFont.caption)
.foregroundColor(.closerTextSecondary)
if isPaired {
NavigationLink {
AnswerRevealView(questionId: answer.questionId)
} label: {
AnswerHistoryRow(answer: answer)
}
} else {
Button {
showCreateInvite = true
} label: {
AnswerHistoryRow(answer: answer)
}
.padding(.vertical, 4)
}
}
}
@ -378,6 +407,10 @@ struct AnswerHistoryView: View {
.background(Color.closerBackground)
.navigationTitle("Answer History")
.navigationBarTitleDisplayMode(.inline)
.fullScreenCover(isPresented: $showCreateInvite) {
CreateInviteView()
.environmentObject(appState)
}
.task {
// Load answers
try? await Task.sleep(nanoseconds: 500_000_000)
@ -386,6 +419,22 @@ struct AnswerHistoryView: View {
}
}
private struct AnswerHistoryRow: View {
let answer: Answer
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(answer.answerText)
.font(CloserFont.body)
.foregroundColor(.closerText)
Text(answer.createdAt, style: .date)
.font(CloserFont.caption)
.foregroundColor(.closerTextSecondary)
}
.padding(.vertical, 4)
}
}
// MARK: - Question Pack Library
struct QuestionPackLibraryView: View {