1e2adf7660
Total rebrand across KMM project: - Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations) - Gradle: rootProject.name, namespace, applicationId - Android: manifest, strings.xml (all languages), widget resources - iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig - iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc. - Swift source: all class/struct/enum renames - Deep links: casera:// -> honeydue://, .casera -> .honeydue - App icons replaced with honeyDue honeycomb icon - Domains: casera.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - Database table names preserved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
380 lines
12 KiB
Kotlin
380 lines
12 KiB
Kotlin
package com.tt.honeyDue.viewmodel
|
|
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import com.tt.honeyDue.data.DataManager
|
|
import com.tt.honeyDue.models.AuthResponse
|
|
import com.tt.honeyDue.models.LoginRequest
|
|
import com.tt.honeyDue.models.RegisterRequest
|
|
import com.tt.honeyDue.models.ResidenceCreateRequest
|
|
import com.tt.honeyDue.models.TaskCreateRequest
|
|
import com.tt.honeyDue.models.TaskTemplate
|
|
import com.tt.honeyDue.models.VerifyEmailRequest
|
|
import com.tt.honeyDue.network.ApiResult
|
|
import com.tt.honeyDue.network.APILayer
|
|
import com.tt.honeyDue.repository.LookupsRepository
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.launch
|
|
|
|
/**
|
|
* User's intent during onboarding
|
|
*/
|
|
enum class OnboardingIntent {
|
|
UNKNOWN,
|
|
START_FRESH, // Creating a new residence
|
|
JOIN_EXISTING // Joining with a share code
|
|
}
|
|
|
|
/**
|
|
* Steps in the onboarding flow
|
|
*/
|
|
enum class OnboardingStep {
|
|
WELCOME,
|
|
VALUE_PROPS,
|
|
NAME_RESIDENCE,
|
|
CREATE_ACCOUNT,
|
|
VERIFY_EMAIL,
|
|
JOIN_RESIDENCE,
|
|
RESIDENCE_LOCATION,
|
|
FIRST_TASK,
|
|
SUBSCRIPTION_UPSELL
|
|
}
|
|
|
|
/**
|
|
* ViewModel for managing the onboarding flow state
|
|
*/
|
|
class OnboardingViewModel : ViewModel() {
|
|
|
|
private val _currentStep = MutableStateFlow(OnboardingStep.WELCOME)
|
|
val currentStep: StateFlow<OnboardingStep> = _currentStep
|
|
|
|
private val _userIntent = MutableStateFlow(OnboardingIntent.UNKNOWN)
|
|
val userIntent: StateFlow<OnboardingIntent> = _userIntent
|
|
|
|
private val _residenceName = MutableStateFlow("")
|
|
val residenceName: StateFlow<String> = _residenceName
|
|
|
|
private val _shareCode = MutableStateFlow("")
|
|
val shareCode: StateFlow<String> = _shareCode
|
|
|
|
// Registration state
|
|
private val _registerState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
|
|
val registerState: StateFlow<ApiResult<AuthResponse>> = _registerState
|
|
|
|
// Login state (for returning users)
|
|
private val _loginState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
|
|
val loginState: StateFlow<ApiResult<AuthResponse>> = _loginState
|
|
|
|
// Email verification state
|
|
private val _verifyEmailState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
|
val verifyEmailState: StateFlow<ApiResult<Unit>> = _verifyEmailState
|
|
|
|
// Residence creation state
|
|
private val _createResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
|
val createResidenceState: StateFlow<ApiResult<Unit>> = _createResidenceState
|
|
|
|
// Join residence state
|
|
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
|
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
|
|
|
|
// Task creation state
|
|
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
|
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
|
|
|
|
// Regional templates state
|
|
private val _regionalTemplates = MutableStateFlow<ApiResult<List<TaskTemplate>>>(ApiResult.Idle)
|
|
val regionalTemplates: StateFlow<ApiResult<List<TaskTemplate>>> = _regionalTemplates
|
|
|
|
// ZIP code entered during location step (persisted on residence)
|
|
private val _postalCode = MutableStateFlow("")
|
|
val postalCode: StateFlow<String> = _postalCode
|
|
|
|
// Whether onboarding is complete
|
|
private val _isComplete = MutableStateFlow(false)
|
|
val isComplete: StateFlow<Boolean> = _isComplete
|
|
|
|
fun setUserIntent(intent: OnboardingIntent) {
|
|
_userIntent.value = intent
|
|
}
|
|
|
|
fun setResidenceName(name: String) {
|
|
_residenceName.value = name
|
|
}
|
|
|
|
fun setShareCode(code: String) {
|
|
_shareCode.value = code
|
|
}
|
|
|
|
/**
|
|
* Move to the next step in the flow
|
|
* Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell
|
|
*/
|
|
fun nextStep() {
|
|
_currentStep.value = when (_currentStep.value) {
|
|
OnboardingStep.WELCOME -> {
|
|
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
|
|
OnboardingStep.CREATE_ACCOUNT
|
|
} else {
|
|
OnboardingStep.VALUE_PROPS
|
|
}
|
|
}
|
|
OnboardingStep.VALUE_PROPS -> OnboardingStep.NAME_RESIDENCE
|
|
OnboardingStep.NAME_RESIDENCE -> OnboardingStep.CREATE_ACCOUNT
|
|
OnboardingStep.CREATE_ACCOUNT -> OnboardingStep.VERIFY_EMAIL
|
|
OnboardingStep.VERIFY_EMAIL -> {
|
|
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
|
|
OnboardingStep.JOIN_RESIDENCE
|
|
} else {
|
|
OnboardingStep.RESIDENCE_LOCATION
|
|
}
|
|
}
|
|
OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL
|
|
OnboardingStep.RESIDENCE_LOCATION -> OnboardingStep.FIRST_TASK
|
|
OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL
|
|
OnboardingStep.SUBSCRIPTION_UPSELL -> {
|
|
completeOnboarding()
|
|
OnboardingStep.SUBSCRIPTION_UPSELL
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Go to a specific step
|
|
*/
|
|
fun goToStep(step: OnboardingStep) {
|
|
_currentStep.value = step
|
|
}
|
|
|
|
/**
|
|
* Go back to the previous step
|
|
*/
|
|
fun previousStep() {
|
|
_currentStep.value = when (_currentStep.value) {
|
|
OnboardingStep.VALUE_PROPS -> OnboardingStep.WELCOME
|
|
OnboardingStep.NAME_RESIDENCE -> OnboardingStep.VALUE_PROPS
|
|
OnboardingStep.CREATE_ACCOUNT -> {
|
|
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
|
|
OnboardingStep.WELCOME
|
|
} else {
|
|
OnboardingStep.NAME_RESIDENCE
|
|
}
|
|
}
|
|
OnboardingStep.VERIFY_EMAIL -> OnboardingStep.CREATE_ACCOUNT
|
|
else -> _currentStep.value
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Skip the current step (for skippable screens)
|
|
*/
|
|
fun skipStep() {
|
|
when (_currentStep.value) {
|
|
OnboardingStep.VALUE_PROPS,
|
|
OnboardingStep.JOIN_RESIDENCE,
|
|
OnboardingStep.RESIDENCE_LOCATION,
|
|
OnboardingStep.FIRST_TASK -> nextStep()
|
|
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
|
|
else -> {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a new user
|
|
*/
|
|
fun register(username: String, email: String, password: String) {
|
|
viewModelScope.launch {
|
|
_registerState.value = ApiResult.Loading
|
|
val result = APILayer.register(
|
|
RegisterRequest(
|
|
username = username,
|
|
email = email,
|
|
password = password
|
|
)
|
|
)
|
|
_registerState.value = when (result) {
|
|
is ApiResult.Success -> {
|
|
DataManager.setAuthToken(result.data.token)
|
|
DataManager.setCurrentUser(result.data.user)
|
|
LookupsRepository.initialize()
|
|
ApiResult.Success(result.data)
|
|
}
|
|
is ApiResult.Error -> result
|
|
else -> ApiResult.Error("Unknown error")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Login an existing user
|
|
*/
|
|
fun login(username: String, password: String) {
|
|
viewModelScope.launch {
|
|
_loginState.value = ApiResult.Loading
|
|
val result = APILayer.login(LoginRequest(username, password))
|
|
_loginState.value = when (result) {
|
|
is ApiResult.Success -> {
|
|
LookupsRepository.initialize()
|
|
ApiResult.Success(result.data)
|
|
}
|
|
is ApiResult.Error -> result
|
|
else -> ApiResult.Error("Unknown error")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify email with 6-digit code
|
|
*/
|
|
fun verifyEmail(code: String) {
|
|
viewModelScope.launch {
|
|
_verifyEmailState.value = ApiResult.Loading
|
|
val token = DataManager.authToken.value ?: run {
|
|
_verifyEmailState.value = ApiResult.Error("Not authenticated")
|
|
return@launch
|
|
}
|
|
val result = APILayer.verifyEmail(token, VerifyEmailRequest(code = code))
|
|
_verifyEmailState.value = when (result) {
|
|
is ApiResult.Success -> ApiResult.Success(Unit)
|
|
is ApiResult.Error -> result
|
|
else -> ApiResult.Error("Unknown error")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create the residence with the name from onboarding
|
|
*/
|
|
fun createResidence() {
|
|
viewModelScope.launch {
|
|
if (_residenceName.value.isBlank()) {
|
|
_createResidenceState.value = ApiResult.Success(Unit)
|
|
return@launch
|
|
}
|
|
|
|
_createResidenceState.value = ApiResult.Loading
|
|
|
|
val result = APILayer.createResidence(
|
|
ResidenceCreateRequest(
|
|
name = _residenceName.value,
|
|
propertyTypeId = null,
|
|
streetAddress = null,
|
|
apartmentUnit = null,
|
|
city = null,
|
|
stateProvince = null,
|
|
postalCode = _postalCode.value.takeIf { it.isNotBlank() },
|
|
country = null,
|
|
bedrooms = null,
|
|
bathrooms = null,
|
|
squareFootage = null,
|
|
lotSize = null,
|
|
yearBuilt = null,
|
|
description = null,
|
|
purchaseDate = null,
|
|
purchasePrice = null,
|
|
isPrimary = true
|
|
)
|
|
)
|
|
|
|
_createResidenceState.value = when (result) {
|
|
is ApiResult.Success -> ApiResult.Success(Unit)
|
|
is ApiResult.Error -> result
|
|
else -> ApiResult.Error("Failed to create residence")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Join an existing residence with a share code
|
|
*/
|
|
fun joinResidence(code: String) {
|
|
viewModelScope.launch {
|
|
_joinResidenceState.value = ApiResult.Loading
|
|
val result = APILayer.joinWithCode(code)
|
|
_joinResidenceState.value = when (result) {
|
|
is ApiResult.Success -> ApiResult.Success(Unit)
|
|
is ApiResult.Error -> result
|
|
else -> ApiResult.Error("Failed to join residence")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create selected tasks during onboarding
|
|
*/
|
|
fun createTasks(taskRequests: List<TaskCreateRequest>) {
|
|
viewModelScope.launch {
|
|
if (taskRequests.isEmpty()) {
|
|
_createTasksState.value = ApiResult.Success(Unit)
|
|
return@launch
|
|
}
|
|
|
|
_createTasksState.value = ApiResult.Loading
|
|
|
|
var successCount = 0
|
|
for (request in taskRequests) {
|
|
val result = APILayer.createTask(request)
|
|
if (result is ApiResult.Success) {
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
_createTasksState.value = if (successCount > 0) {
|
|
ApiResult.Success(Unit)
|
|
} else {
|
|
ApiResult.Error("Failed to create tasks")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load regional templates by ZIP code (backend resolves ZIP → state → climate zone).
|
|
* Also stores the ZIP code for later use when creating the residence.
|
|
*/
|
|
fun loadRegionalTemplates(zip: String) {
|
|
_postalCode.value = zip
|
|
viewModelScope.launch {
|
|
_regionalTemplates.value = ApiResult.Loading
|
|
_regionalTemplates.value = APILayer.getRegionalTemplates(zip = zip)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark onboarding as complete
|
|
*/
|
|
fun completeOnboarding() {
|
|
_isComplete.value = true
|
|
}
|
|
|
|
/**
|
|
* Reset all state (useful for testing)
|
|
*/
|
|
fun reset() {
|
|
_currentStep.value = OnboardingStep.WELCOME
|
|
_userIntent.value = OnboardingIntent.UNKNOWN
|
|
_residenceName.value = ""
|
|
_shareCode.value = ""
|
|
_registerState.value = ApiResult.Idle
|
|
_loginState.value = ApiResult.Idle
|
|
_verifyEmailState.value = ApiResult.Idle
|
|
_createResidenceState.value = ApiResult.Idle
|
|
_joinResidenceState.value = ApiResult.Idle
|
|
_createTasksState.value = ApiResult.Idle
|
|
_regionalTemplates.value = ApiResult.Idle
|
|
_postalCode.value = ""
|
|
_isComplete.value = false
|
|
}
|
|
|
|
fun resetRegisterState() {
|
|
_registerState.value = ApiResult.Idle
|
|
}
|
|
|
|
fun resetLoginState() {
|
|
_loginState.value = ApiResult.Idle
|
|
}
|
|
|
|
fun resetVerifyEmailState() {
|
|
_verifyEmailState.value = ApiResult.Idle
|
|
}
|
|
}
|