From 7db075d195d70b33799a29549ea4defd2080575b Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 19:44:44 -0500 Subject: [PATCH] feat: navigation, answer history screen + viewmodel, answer reveal, iOS navigation & question views --- .../closer/core/navigation/AppNavigation.kt | 1 + .../closer/ui/answers/AnswerHistoryScreen.kt | 44 +++++++++-- .../ui/answers/AnswerHistoryViewModel.kt | 26 ++++-- .../closer/ui/answers/AnswerRevealScreen.kt | 6 ++ iphone/Closer/Navigation/ContentView.swift | 29 ++++++- iphone/Closer/Questions/QuestionViews.swift | 79 +++++++++++++++---- 6 files changed, 153 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 0de477bf..47c70f59 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/answers/AnswerHistoryScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerHistoryScreen.kt index 415219a3..ef7b0a0c 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerHistoryScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerHistoryScreen.kt @@ -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(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 = {} ) } diff --git a/app/src/main/java/app/closer/ui/answers/AnswerHistoryViewModel.kt b/app/src/main/java/app/closer/ui/answers/AnswerHistoryViewModel.kt index 9a74d826..89b68ec8 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerHistoryViewModel.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerHistoryViewModel.kt @@ -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 = emptyList() + val answers: List = 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 = _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) diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt index 57acf140..61e4c40e 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -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, diff --git a/iphone/Closer/Navigation/ContentView.swift b/iphone/Closer/Navigation/ContentView.swift index 67e8c8f8..81f833ca 100644 --- a/iphone/Closer/Navigation/ContentView.swift +++ b/iphone/Closer/Navigation/ContentView.swift @@ -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 \ No newline at end of file +import Foundation diff --git a/iphone/Closer/Questions/QuestionViews.swift b/iphone/Closer/Questions/QuestionViews.swift index 1d078800..9061a802 100644 --- a/iphone/Closer/Questions/QuestionViews.swift +++ b/iphone/Closer/Questions/QuestionViews.swift @@ -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 {