2026-06-16 20:03:58 -05:00
|
|
|
package app.closer.ui.home
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
|
2026-06-17 20:51:18 -05:00
|
|
|
import android.util.Log
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
import androidx.lifecycle.ViewModel
|
|
|
|
|
import androidx.lifecycle.viewModelScope
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
import app.closer.crypto.CoupleEncryptionManager
|
|
|
|
|
import app.closer.crypto.EncryptionStatus
|
2026-06-16 20:03:58 -05:00
|
|
|
import app.closer.domain.model.LocalAnswer
|
|
|
|
|
import app.closer.domain.model.Question
|
|
|
|
|
import app.closer.domain.model.QuestionCategory
|
|
|
|
|
import app.closer.domain.repository.AuthRepository
|
|
|
|
|
import app.closer.domain.repository.CoupleRepository
|
|
|
|
|
import app.closer.domain.repository.LocalAnswerRepository
|
|
|
|
|
import app.closer.domain.repository.QuestionRepository
|
|
|
|
|
import app.closer.domain.repository.UserRepository
|
2026-06-18 00:25:52 -05:00
|
|
|
import com.google.firebase.firestore.FirebaseFirestore
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
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 HomeCategorySummary(
|
|
|
|
|
val category: QuestionCategory,
|
|
|
|
|
val questionCount: Int
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
data class HomeAnswerStats(
|
|
|
|
|
val total: Int = 0,
|
|
|
|
|
val revealed: Int = 0,
|
|
|
|
|
val private: Int = 0,
|
2026-06-16 03:25:03 -05:00
|
|
|
val latest: LocalAnswer? = null,
|
|
|
|
|
val answeredQuestionIds: Set<String> = emptySet()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
enum class HomeActionTarget {
|
|
|
|
|
InvitePartner,
|
|
|
|
|
DailyQuestion,
|
|
|
|
|
AnswerHistory,
|
|
|
|
|
QuestionPacks,
|
|
|
|
|
Settings
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum class HomeActionTone {
|
|
|
|
|
Invite,
|
|
|
|
|
Daily,
|
|
|
|
|
Reflection,
|
|
|
|
|
Ritual,
|
|
|
|
|
Starter,
|
|
|
|
|
Pack,
|
|
|
|
|
Utility
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data class HomeAction(
|
|
|
|
|
val eyebrow: String,
|
|
|
|
|
val title: String,
|
|
|
|
|
val body: String,
|
|
|
|
|
val cta: String,
|
|
|
|
|
val target: HomeActionTarget,
|
|
|
|
|
val tone: HomeActionTone,
|
|
|
|
|
val metric: String? = null,
|
|
|
|
|
val categoryId: String? = null
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
)
|
|
|
|
|
|
2026-06-19 22:20:49 -05:00
|
|
|
enum class DailyQuestionState {
|
|
|
|
|
UNANSWERED,
|
|
|
|
|
USER_ANSWERED_PARTNER_PENDING,
|
|
|
|
|
PARTNER_ANSWERED_USER_PENDING,
|
|
|
|
|
BOTH_ANSWERED,
|
|
|
|
|
REVEALED
|
|
|
|
|
}
|
|
|
|
|
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
data class HomeUiState(
|
|
|
|
|
val isLoading: Boolean = true,
|
|
|
|
|
val error: String? = null,
|
|
|
|
|
val dailyQuestion: Question? = null,
|
|
|
|
|
val categories: List<HomeCategorySummary> = emptyList(),
|
2026-06-16 00:50:13 -05:00
|
|
|
val answerStats: HomeAnswerStats = HomeAnswerStats(),
|
|
|
|
|
val partnerName: String? = null,
|
2026-06-16 01:57:48 -05:00
|
|
|
val streakCount: Int = 0,
|
2026-06-16 03:25:03 -05:00
|
|
|
val isPaired: Boolean = false,
|
|
|
|
|
val primaryAction: HomeAction? = null,
|
2026-06-18 00:25:52 -05:00
|
|
|
val secondaryActions: List<HomeAction> = emptyList(),
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
val partnerLeftEvent: Boolean = false,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
val needsRecovery: Boolean = false,
|
2026-06-19 22:20:49 -05:00
|
|
|
val needsEncryptionUpgrade: Boolean = false,
|
|
|
|
|
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
|
|
|
|
|
val hasPartnerAnsweredToday: Boolean = false,
|
|
|
|
|
val partnerAnsweredQuestionId: String? = null,
|
|
|
|
|
val hasRevealedToday: Boolean = false
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@HiltViewModel
|
|
|
|
|
class HomeViewModel @Inject constructor(
|
|
|
|
|
private val questionRepository: QuestionRepository,
|
2026-06-16 00:50:13 -05:00
|
|
|
private val localAnswerRepository: LocalAnswerRepository,
|
|
|
|
|
private val authRepository: AuthRepository,
|
|
|
|
|
private val coupleRepository: CoupleRepository,
|
2026-06-18 00:25:52 -05:00
|
|
|
private val userRepository: UserRepository,
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
private val encryptionManager: CoupleEncryptionManager,
|
2026-06-18 00:25:52 -05:00
|
|
|
private val db: FirebaseFirestore
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
) : ViewModel() {
|
|
|
|
|
|
|
|
|
|
private val _uiState = MutableStateFlow(HomeUiState())
|
|
|
|
|
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
|
|
|
|
|
2026-06-18 00:25:52 -05:00
|
|
|
private var coupleStateListener: com.google.firebase.firestore.ListenerRegistration? = null
|
2026-06-19 22:20:49 -05:00
|
|
|
private var partnerAnswerListener: com.google.firebase.firestore.ListenerRegistration? = null
|
2026-06-18 00:25:52 -05:00
|
|
|
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
init {
|
|
|
|
|
loadHome()
|
|
|
|
|
observeAnswers()
|
2026-06-18 00:25:52 -05:00
|
|
|
observeCoupleState()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onCleared() {
|
|
|
|
|
super.onCleared()
|
|
|
|
|
coupleStateListener?.remove()
|
|
|
|
|
coupleStateListener = null
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun loadHome() {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
|
|
|
|
try {
|
|
|
|
|
val dailyQuestion = questionRepository.getDailyQuestion()
|
|
|
|
|
val categories = questionRepository.getCategories()
|
|
|
|
|
.take(6)
|
|
|
|
|
.map { category ->
|
|
|
|
|
HomeCategorySummary(
|
|
|
|
|
category = category,
|
|
|
|
|
questionCount = questionRepository.getQuestionCountByCategory(category.id)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-16 00:50:13 -05:00
|
|
|
val uid = authRepository.currentUserId
|
|
|
|
|
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
|
|
|
|
|
val partnerName = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId ->
|
2026-06-17 20:51:18 -05:00
|
|
|
runCatching { userRepository.getUser(partnerId)?.displayName }
|
|
|
|
|
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
|
|
|
|
|
.getOrNull()
|
2026-06-16 00:50:13 -05:00
|
|
|
}
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
val encryptionStatus = couple?.let(encryptionManager::checkStatus)
|
|
|
|
|
val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY
|
|
|
|
|
val needsEncryptionUpgrade = when (encryptionStatus) {
|
|
|
|
|
EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE -> true
|
|
|
|
|
EncryptionStatus.NEEDS_CONTENT_MIGRATION ->
|
|
|
|
|
couple.encryptionMigrationUsers[uid] != true
|
|
|
|
|
else -> false
|
|
|
|
|
}
|
2026-06-19 22:20:49 -05:00
|
|
|
_uiState.update { current ->
|
|
|
|
|
current.copy(
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
isLoading = false,
|
|
|
|
|
dailyQuestion = dailyQuestion,
|
2026-06-16 00:50:13 -05:00
|
|
|
categories = categories,
|
|
|
|
|
partnerName = partnerName,
|
2026-06-16 01:57:48 -05:00
|
|
|
streakCount = couple?.streakCount ?: 0,
|
2026-06-18 00:25:52 -05:00
|
|
|
isPaired = couple != null,
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
partnerLeftEvent = false,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
needsRecovery = needsRecovery,
|
|
|
|
|
needsEncryptionUpgrade = needsEncryptionUpgrade
|
2026-06-19 22:20:49 -05:00
|
|
|
).refreshDailyQuestionState().withHomeActions()
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
}
|
2026-06-19 22:20:49 -05:00
|
|
|
observePartnerAnswer(couple?.id, couple?.userIds, dailyQuestion?.id)
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
} catch (e: Exception) {
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
isLoading = false,
|
2026-06-19 04:20:08 -05:00
|
|
|
error = e.message ?: "Couldn't load your space right now."
|
2026-06-16 03:25:03 -05:00
|
|
|
).withHomeActions()
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 00:25:52 -05:00
|
|
|
private fun observeCoupleState() {
|
|
|
|
|
val uid = authRepository.currentUserId ?: return
|
|
|
|
|
coupleStateListener?.remove()
|
|
|
|
|
coupleStateListener = db.collection("users").document(uid)
|
|
|
|
|
.addSnapshotListener(com.google.firebase.firestore.MetadataChanges.INCLUDE) { snapshot, error ->
|
|
|
|
|
if (error != null || snapshot == null || !snapshot.exists()) {
|
|
|
|
|
return@addSnapshotListener
|
|
|
|
|
}
|
|
|
|
|
val newCoupleId = snapshot.getString("coupleId")
|
|
|
|
|
val oldIsPaired = _uiState.value.isPaired
|
|
|
|
|
val oldPartnerName = _uiState.value.partnerName
|
|
|
|
|
val newIsPaired = !newCoupleId.isNullOrBlank()
|
|
|
|
|
|
|
|
|
|
if (newIsPaired != oldIsPaired) {
|
|
|
|
|
// Capture partner name before reload so the banner can reference it.
|
|
|
|
|
val showPartnerLeftBanner = oldIsPaired && !newIsPaired
|
|
|
|
|
if (showPartnerLeftBanner) {
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
isPaired = false,
|
|
|
|
|
partnerName = null,
|
|
|
|
|
partnerLeftEvent = true
|
|
|
|
|
).withHomeActions()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
loadHome()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Consumes the partner-left banner event. Call from the UI after showing it.
|
|
|
|
|
*/
|
|
|
|
|
fun consumePartnerLeftEvent() {
|
|
|
|
|
_uiState.update { it.copy(partnerLeftEvent = false) }
|
|
|
|
|
}
|
|
|
|
|
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
/** Called after the recovery flow completes so the banner goes away. */
|
|
|
|
|
fun onRecoveryCompleted() {
|
|
|
|
|
_uiState.update { it.copy(needsRecovery = false) }
|
|
|
|
|
loadHome()
|
|
|
|
|
}
|
|
|
|
|
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
private fun observeAnswers() {
|
|
|
|
|
viewModelScope.launch {
|
|
|
|
|
localAnswerRepository.observeAnswers().collect { answers ->
|
|
|
|
|
val sorted = answers.sortedByDescending { it.updatedAt }
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
answerStats = HomeAnswerStats(
|
|
|
|
|
total = answers.size,
|
|
|
|
|
revealed = answers.count { answer -> answer.isRevealed },
|
|
|
|
|
private = answers.count { answer -> !answer.isRevealed },
|
2026-06-16 03:25:03 -05:00
|
|
|
latest = sorted.firstOrNull(),
|
|
|
|
|
answeredQuestionIds = answers.map { answer -> answer.questionId }.toSet()
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
)
|
2026-06-19 22:20:49 -05:00
|
|
|
).refreshDailyQuestionState().withHomeActions()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun observePartnerAnswer(
|
|
|
|
|
coupleId: String?,
|
|
|
|
|
coupleUserIds: List<String>?,
|
|
|
|
|
dailyQuestionId: String?
|
|
|
|
|
) {
|
|
|
|
|
partnerAnswerListener?.remove()
|
|
|
|
|
partnerAnswerListener = null
|
|
|
|
|
val cId = coupleId ?: return
|
|
|
|
|
val qId = dailyQuestionId ?: return
|
|
|
|
|
val uid = authRepository.currentUserId ?: return
|
|
|
|
|
val partnerId = coupleUserIds?.firstOrNull { it != uid } ?: return
|
|
|
|
|
|
|
|
|
|
val today = FirestoreAnswerDataSource.todayLocalDateString()
|
|
|
|
|
partnerAnswerListener = db.collection("couples")
|
|
|
|
|
.document(cId)
|
|
|
|
|
.collection("daily_question")
|
|
|
|
|
.document(today)
|
|
|
|
|
.collection("answers")
|
|
|
|
|
.document(partnerId)
|
|
|
|
|
.addSnapshotListener(com.google.firebase.firestore.MetadataChanges.INCLUDE) { snapshot, error ->
|
|
|
|
|
if (error != null) {
|
|
|
|
|
Log.w(TAG, "Partner answer listener failed", error)
|
|
|
|
|
return@addSnapshotListener
|
|
|
|
|
}
|
|
|
|
|
val hasPartnerAnswer = snapshot?.exists() == true
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
hasPartnerAnsweredToday = hasPartnerAnswer,
|
|
|
|
|
partnerAnsweredQuestionId = if (hasPartnerAnswer) qId else null
|
|
|
|
|
).refreshDailyQuestionState().withHomeActions()
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-19 22:20:49 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun HomeUiState.refreshDailyQuestionState(): HomeUiState {
|
|
|
|
|
val questionId = dailyQuestion?.id
|
|
|
|
|
val userAnswered = questionId != null && questionId in answerStats.answeredQuestionIds
|
|
|
|
|
val userRevealed = questionId != null && answerStats.latest?.let { latest ->
|
|
|
|
|
latest.questionId == questionId && latest.isRevealed
|
|
|
|
|
} == true
|
|
|
|
|
|
|
|
|
|
val state = when {
|
|
|
|
|
questionId == null -> DailyQuestionState.UNANSWERED
|
|
|
|
|
userRevealed -> DailyQuestionState.REVEALED
|
|
|
|
|
userAnswered && hasPartnerAnsweredToday -> DailyQuestionState.BOTH_ANSWERED
|
|
|
|
|
userAnswered -> DailyQuestionState.USER_ANSWERED_PARTNER_PENDING
|
|
|
|
|
hasPartnerAnsweredToday -> DailyQuestionState.PARTNER_ANSWERED_USER_PENDING
|
|
|
|
|
else -> DailyQuestionState.UNANSWERED
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
}
|
2026-06-19 22:20:49 -05:00
|
|
|
return copy(
|
|
|
|
|
dailyQuestionState = state,
|
|
|
|
|
hasRevealedToday = userRevealed
|
|
|
|
|
)
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
}
|
2026-06-16 03:25:03 -05:00
|
|
|
|
|
|
|
|
private fun HomeUiState.withHomeActions(): HomeUiState {
|
|
|
|
|
if (isLoading || error != null) {
|
|
|
|
|
return copy(primaryAction = null, secondaryActions = emptyList())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val primary = buildPrimaryAction()
|
|
|
|
|
return copy(
|
|
|
|
|
primaryAction = primary,
|
|
|
|
|
secondaryActions = buildSecondaryActions(primary)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun HomeUiState.buildPrimaryAction(): HomeAction {
|
2026-06-19 22:20:49 -05:00
|
|
|
val dailyQuestionId = dailyQuestion?.id
|
|
|
|
|
val userAnswered = dailyQuestionId != null && dailyQuestionId in answerStats.answeredQuestionIds
|
|
|
|
|
val userRevealed = dailyQuestionId != null && answerStats.latest?.let { latest ->
|
|
|
|
|
latest.questionId == dailyQuestionId && latest.isRevealed
|
|
|
|
|
} == true
|
2026-06-16 03:25:03 -05:00
|
|
|
|
|
|
|
|
return when {
|
|
|
|
|
!isPaired -> HomeAction(
|
|
|
|
|
eyebrow = "Next best action",
|
|
|
|
|
title = "Invite your partner into tonight.",
|
|
|
|
|
body = "The app works best as a shared ritual. Send a private invite and make the next prompt something you can both answer.",
|
|
|
|
|
cta = "Invite partner",
|
|
|
|
|
target = HomeActionTarget.InvitePartner,
|
|
|
|
|
tone = HomeActionTone.Invite
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-19 22:20:49 -05:00
|
|
|
userRevealed -> HomeAction(
|
|
|
|
|
eyebrow = "Tonight's prompt",
|
|
|
|
|
title = "You opened a conversation tonight.",
|
|
|
|
|
body = dailyQuestion?.text ?: "You revealed an answer together. What comes next is up to both of you.",
|
|
|
|
|
cta = "Try a follow-up",
|
|
|
|
|
target = HomeActionTarget.DailyQuestion,
|
|
|
|
|
tone = HomeActionTone.Daily,
|
|
|
|
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
dailyQuestionState == DailyQuestionState.BOTH_ANSWERED -> HomeAction(
|
|
|
|
|
eyebrow = "Tonight's prompt",
|
|
|
|
|
title = "Reveal is ready.",
|
|
|
|
|
body = "Both of you answered. Open it together when you are both in the right headspace.",
|
|
|
|
|
cta = "Reveal together",
|
|
|
|
|
target = HomeActionTarget.DailyQuestion,
|
|
|
|
|
tone = HomeActionTone.Daily,
|
|
|
|
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> HomeAction(
|
|
|
|
|
eyebrow = "Tonight's prompt",
|
|
|
|
|
title = "You showed up tonight. Waiting for your partner.",
|
|
|
|
|
body = "Your answer is private until they answer too. No pressure — the reveal waits for both of you.",
|
|
|
|
|
cta = "Send a gentle reminder",
|
|
|
|
|
target = HomeActionTarget.DailyQuestion,
|
|
|
|
|
tone = HomeActionTone.Daily,
|
|
|
|
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> HomeAction(
|
|
|
|
|
eyebrow = "Tonight's prompt",
|
|
|
|
|
title = "Your partner answered. Your turn.",
|
|
|
|
|
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
|
|
|
|
|
cta = "Answer to unlock reveal",
|
|
|
|
|
target = HomeActionTarget.DailyQuestion,
|
|
|
|
|
tone = HomeActionTone.Daily,
|
|
|
|
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
userAnswered -> HomeAction(
|
2026-06-16 03:25:03 -05:00
|
|
|
eyebrow = "Tonight's prompt",
|
|
|
|
|
title = dailyQuestion?.text ?: "Answer tonight's question.",
|
|
|
|
|
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
|
|
|
|
|
cta = "Answer now",
|
|
|
|
|
target = HomeActionTarget.DailyQuestion,
|
|
|
|
|
tone = HomeActionTone.Daily,
|
|
|
|
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
answerStats.private > 0 -> HomeAction(
|
|
|
|
|
eyebrow = "Saved privately",
|
|
|
|
|
title = "You have ${answerStats.private} reflection${if (answerStats.private == 1) "" else "s"} waiting.",
|
|
|
|
|
body = "Review what you saved and choose whether tonight is the right time to open one up.",
|
|
|
|
|
cta = "Review reflections",
|
|
|
|
|
target = HomeActionTarget.AnswerHistory,
|
|
|
|
|
tone = HomeActionTone.Reflection,
|
|
|
|
|
metric = "${answerStats.revealed} revealed"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
streakCount > 0 -> HomeAction(
|
|
|
|
|
eyebrow = "Shared ritual",
|
|
|
|
|
title = "$streakCount night${if (streakCount == 1) "" else "s"} showing up.",
|
|
|
|
|
body = "Keep it light: answer one prompt, revisit a saved reflection, or choose a pack that fits tonight.",
|
|
|
|
|
cta = "Keep going",
|
|
|
|
|
target = HomeActionTarget.DailyQuestion,
|
|
|
|
|
tone = HomeActionTone.Ritual,
|
|
|
|
|
metric = "${answerStats.total} saved"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
else -> HomeAction(
|
|
|
|
|
eyebrow = "Gentle start",
|
2026-06-19 22:20:49 -05:00
|
|
|
title = "Tonight's question is ready.",
|
|
|
|
|
body = "${dailyQuestion?.text ?: "A small prompt is enough. Build the habit around attention, not pressure."}",
|
|
|
|
|
cta = "Answer privately",
|
2026-06-16 03:25:03 -05:00
|
|
|
target = HomeActionTarget.DailyQuestion,
|
2026-06-19 22:20:49 -05:00
|
|
|
tone = HomeActionTone.Starter,
|
|
|
|
|
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
2026-06-16 03:25:03 -05:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun HomeUiState.buildSecondaryActions(primary: HomeAction): List<HomeAction> {
|
|
|
|
|
val actions = mutableListOf<HomeAction>()
|
|
|
|
|
|
|
|
|
|
answerStats.latest?.let { latest ->
|
|
|
|
|
if (primary.target != HomeActionTarget.AnswerHistory) {
|
|
|
|
|
actions += HomeAction(
|
|
|
|
|
eyebrow = if (latest.isRevealed) "Revealed" else "Private",
|
|
|
|
|
title = "Return to your latest reflection.",
|
|
|
|
|
body = latest.questionText,
|
|
|
|
|
cta = "Open history",
|
|
|
|
|
target = HomeActionTarget.AnswerHistory,
|
|
|
|
|
tone = HomeActionTone.Reflection
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
categories.firstOrNull()?.let { category ->
|
|
|
|
|
actions += HomeAction(
|
|
|
|
|
eyebrow = "Suggested pack",
|
|
|
|
|
title = category.category.displayName.ifBlank { "Question pack" },
|
|
|
|
|
body = "${category.questionCount} prompts for when you want a different doorway into the conversation.",
|
|
|
|
|
cta = "Open pack",
|
|
|
|
|
target = HomeActionTarget.QuestionPacks,
|
|
|
|
|
tone = HomeActionTone.Pack,
|
|
|
|
|
categoryId = category.category.id
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
actions += HomeAction(
|
|
|
|
|
eyebrow = "Tune the ritual",
|
|
|
|
|
title = "Adjust your space.",
|
|
|
|
|
body = "Manage reminders, partner state, privacy, and account details when you need to.",
|
|
|
|
|
cta = "Settings",
|
|
|
|
|
target = HomeActionTarget.Settings,
|
|
|
|
|
tone = HomeActionTone.Utility
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return actions.take(3)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun String.toHomeLabel(): String =
|
|
|
|
|
split("_", "-")
|
|
|
|
|
.filter { part -> part.isNotBlank() }
|
|
|
|
|
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
|
2026-06-17 20:51:18 -05:00
|
|
|
|
|
|
|
|
companion object {
|
|
|
|
|
private const val TAG = "HomeViewModel"
|
|
|
|
|
}
|
feat(ui): navigation refactor, screen wiring, local answer persistence
- Refactored AppNavigation with route-based screen graph
- Wired all screens (auth, onboarding, pairing, home, wheel, questions, settings)
- Added local answer repository (SharedPreferences-based)
- Added HomeViewModel, AnswerHistoryViewModel, AnswerRevealViewModel
- Added question category browsing, pack library, composer screens
- Cleaned up DailyQuestionScreen/QuestionThreadScreen to use shared components
- Projected route docs, account/settings screens, new drawable resources
2026-06-15 23:48:55 -05:00
|
|
|
}
|