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_COMPOSER,
AppRoute.QUESTION_THREAD, AppRoute.QUESTION_THREAD,
AppRoute.ANSWER_REVEAL, AppRoute.ANSWER_REVEAL,
AppRoute.ANSWER_HISTORY,
AppRoute.CATEGORY_PICKER, AppRoute.CATEGORY_PICKER,
AppRoute.SPIN_WHEEL, AppRoute.SPIN_WHEEL,
AppRoute.WHEEL_SESSION, AppRoute.WHEEL_SESSION,

View File

@ -70,8 +70,16 @@ fun AnswerHistoryScreen(
AnswerHistoryContent( AnswerHistoryContent(
state = state, state = state,
onAnswerSelected = { onNavigate(AppRoute.answerReveal(it.questionId)) }, onAnswerSelected = {
onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) }, 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 onDelete = viewModel::deleteAnswer
) )
} }
@ -80,7 +88,7 @@ fun AnswerHistoryScreen(
private fun AnswerHistoryContent( private fun AnswerHistoryContent(
state: AnswerHistoryUiState, state: AnswerHistoryUiState,
onAnswerSelected: (LocalAnswer) -> Unit, onAnswerSelected: (LocalAnswer) -> Unit,
onDailyQuestion: () -> Unit, onEmptyAction: () -> Unit,
onDelete: (String) -> Unit onDelete: (String) -> Unit
) { ) {
var pendingDelete by remember { mutableStateOf<LocalAnswer?>(null) } var pendingDelete by remember { mutableStateOf<LocalAnswer?>(null) }
@ -159,11 +167,31 @@ private fun AnswerHistoryContent(
if (state.answers.isEmpty()) { if (state.answers.isEmpty()) {
item { 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( EmptyState(
title = "Nothing here yet", title = title,
body = "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here.", body = body,
actionLabel = "Today's question", actionLabel = actionLabel,
onAction = onDailyQuestion, onAction = onAction,
illustrationResId = R.drawable.illustration_couple_history illustrationResId = R.drawable.illustration_couple_history
) )
} }
@ -399,7 +427,7 @@ fun AnswerHistoryScreenPreview() {
) )
), ),
onAnswerSelected = {}, onAnswerSelected = {},
onDailyQuestion = {}, onEmptyAction = {},
onDelete = {} onDelete = {}
) )
} }

View File

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

View File

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

View File

@ -46,6 +46,8 @@ struct ContentView: View {
struct MainTabView: View { struct MainTabView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@State private var selectedTab: Tab = .home @State private var selectedTab: Tab = .home
@State private var showCreateInvite = false
@State private var showAnswerHistory = false
enum Tab: Hashable { enum Tab: Hashable {
case home, dailyQuestion, play, questionPacks, settings case home, dailyQuestion, play, questionPacks, settings
@ -85,10 +87,10 @@ struct MainTabView: View {
} }
.tint(.closerPrimary) .tint(.closerPrimary)
.onReceive(NotificationCenter.default.publisher(for: .navigateToDailyQuestion)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToDailyQuestion)) { _ in
selectedTab = .dailyQuestion routeToDailyOrInvite()
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToReveal)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToReveal)) { _ in
selectedTab = .dailyQuestion routeToDailyOrInvite()
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
selectedTab = .home selectedTab = .home
@ -103,11 +105,30 @@ struct MainTabView: View {
selectedTab = .play selectedTab = .play
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToAnswerHistory)) { _ in .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 .onReceive(NotificationCenter.default.publisher(for: .navigateToSettings)) { _ in
selectedTab = .settings 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 // MARK: - Import for Secret
import Foundation import Foundation

View File

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