feat: navigation, answer history screen + viewmodel, answer reveal, iOS navigation & question views
This commit is contained in:
parent
125a24eb85
commit
7db075d195
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue