P1: All Kotlin VMs align with DataManager single-source-of-truth

Four broken VMs refactored to derive read-state from IDataManager, three
gaps closed:

1. TaskViewModel: tasksState / tasksByResidenceState / taskCompletionsState
   now derived via .map + .stateIn / combine. isLoading / loadError separated.
2. ResidenceViewModel: residencesState / myResidencesState / summaryState /
   residenceTasksState / residenceContractorsState all derived. 8 mutation
   states retained as independent (legit one-shot feedback).
3. ContractorViewModel: contractorsState / contractorDetailState derived.
   4 mutation states retained.
4. DocumentViewModel: documentsState / documentDetailState derived. 6
   mutation states retained.
5. AuthViewModel: currentUserState now derived from dataManager.currentUser.
   10 other states stay independent (one-shot mutation feedback by design).
6. LookupsViewModel: accepts IDataManager ctor param for test injection
   consistency. Direct-exposure pattern preserved. Legacy ApiResult-wrapped
   states now derived from DataManager instead of manual _xxxState.value =.
7. NotificationPreferencesViewModel: preferencesState derived from new
   IDataManager.notificationPreferences. APILayer writes through on both
   getNotificationPreferences and updateNotificationPreferences.

IDataManager also grew notificationPreferences: StateFlow<NotificationPreference?>.
DataManager, InMemoryDataManager updated. No screen edits needed — screens
consume viewModel.xxxState the same way; the source just switched.

Architecture enforcement test comes in P3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-19 18:42:40 -05:00
parent 2230cde071
commit f0f8dfb68b
11 changed files with 489 additions and 330 deletions

View File

@@ -207,6 +207,13 @@ object DataManager : IDataManager {
private val _taskTemplatesGrouped = MutableStateFlow<TaskTemplatesGroupedResponse?>(null) private val _taskTemplatesGrouped = MutableStateFlow<TaskTemplatesGroupedResponse?>(null)
override val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = _taskTemplatesGrouped.asStateFlow() override val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = _taskTemplatesGrouped.asStateFlow()
private val _notificationPreferences = MutableStateFlow<com.tt.honeyDue.models.NotificationPreference?>(null)
override val notificationPreferences: StateFlow<com.tt.honeyDue.models.NotificationPreference?> = _notificationPreferences.asStateFlow()
fun setNotificationPreferences(prefs: com.tt.honeyDue.models.NotificationPreference?) {
_notificationPreferences.value = prefs
}
// Map-based for O(1) ID resolution // Map-based for O(1) ID resolution
private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap()) private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap())
val residenceTypesMap: StateFlow<Map<Int, ResidenceType>> = _residenceTypesMap.asStateFlow() val residenceTypesMap: StateFlow<Map<Int, ResidenceType>> = _residenceTypesMap.asStateFlow()

View File

@@ -112,6 +112,11 @@ interface IDataManager {
val taskTemplates: StateFlow<List<TaskTemplate>> val taskTemplates: StateFlow<List<TaskTemplate>>
val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?>
// ==================== NOTIFICATION PREFERENCES ====================
/** User's server-backed notification preferences. Populated by APILayer.getNotificationPreferences. */
val notificationPreferences: StateFlow<com.tt.honeyDue.models.NotificationPreference?>
// ==================== O(1) LOOKUP HELPERS ==================== // ==================== O(1) LOOKUP HELPERS ====================
fun getResidenceType(id: Int?): ResidenceType? fun getResidenceType(id: Int?): ResidenceType?

View File

@@ -1430,12 +1430,16 @@ object APILayer {
suspend fun getNotificationPreferences(): ApiResult<NotificationPreference> { suspend fun getNotificationPreferences(): ApiResult<NotificationPreference> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.getNotificationPreferences(token) val result = notificationApi.getNotificationPreferences(token)
if (result is ApiResult.Success) DataManager.setNotificationPreferences(result.data)
return result
} }
suspend fun updateNotificationPreferences(request: UpdateNotificationPreferencesRequest): ApiResult<NotificationPreference> { suspend fun updateNotificationPreferences(request: UpdateNotificationPreferencesRequest): ApiResult<NotificationPreference> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return notificationApi.updateNotificationPreferences(token, request) val result = notificationApi.updateNotificationPreferences(token, request)
if (result is ApiResult.Success) DataManager.setNotificationPreferences(result.data)
return result
} }
suspend fun getNotificationHistory(): ApiResult<List<Notification>> { suspend fun getNotificationHistory(): ApiResult<List<Notification>> {

View File

@@ -61,6 +61,7 @@ class InMemoryDataManager(
contractorSpecialties: List<ContractorSpecialty> = emptyList(), contractorSpecialties: List<ContractorSpecialty> = emptyList(),
taskTemplates: List<TaskTemplate> = emptyList(), taskTemplates: List<TaskTemplate> = emptyList(),
taskTemplatesGrouped: TaskTemplatesGroupedResponse? = null, taskTemplatesGrouped: TaskTemplatesGroupedResponse? = null,
notificationPreferences: com.tt.honeyDue.models.NotificationPreference? = null,
) : IDataManager { ) : IDataManager {
// ==================== AUTH ==================== // ==================== AUTH ====================
@@ -112,6 +113,8 @@ class InMemoryDataManager(
override val taskTemplates: StateFlow<List<TaskTemplate>> = MutableStateFlow(taskTemplates) override val taskTemplates: StateFlow<List<TaskTemplate>> = MutableStateFlow(taskTemplates)
override val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = MutableStateFlow(taskTemplatesGrouped) override val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = MutableStateFlow(taskTemplatesGrouped)
override val notificationPreferences: StateFlow<com.tt.honeyDue.models.NotificationPreference?> = MutableStateFlow(notificationPreferences)
// ==================== LOOKUP HELPERS ==================== // ==================== LOOKUP HELPERS ====================
override fun getResidenceType(id: Int?): ResidenceType? = override fun getResidenceType(id: Int?): ResidenceType? =

View File

@@ -3,6 +3,7 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.models.AppleSignInRequest import com.tt.honeyDue.models.AppleSignInRequest
import com.tt.honeyDue.models.AppleSignInResponse import com.tt.honeyDue.models.AppleSignInResponse
import com.tt.honeyDue.models.GoogleSignInRequest import com.tt.honeyDue.models.GoogleSignInRequest
@@ -23,10 +24,15 @@ import com.tt.honeyDue.models.VerifyResetCodeResponse
import com.tt.honeyDue.network.ApiResult import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer import com.tt.honeyDue.network.APILayer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class AuthViewModel : ViewModel() { class AuthViewModel(
private val dataManager: IDataManager = DataManager,
) : ViewModel() {
private val _loginState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle) private val _loginState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
val loginState: StateFlow<ApiResult<AuthResponse>> = _loginState val loginState: StateFlow<ApiResult<AuthResponse>> = _loginState
@@ -40,8 +46,11 @@ class AuthViewModel : ViewModel() {
private val _updateProfileState = MutableStateFlow<ApiResult<User>>(ApiResult.Idle) private val _updateProfileState = MutableStateFlow<ApiResult<User>>(ApiResult.Idle)
val updateProfileState: StateFlow<ApiResult<User>> = _updateProfileState val updateProfileState: StateFlow<ApiResult<User>> = _updateProfileState
private val _currentUserState = MutableStateFlow<ApiResult<User>>(ApiResult.Idle) /** Current authenticated user — derived from [IDataManager.currentUser]. APILayer writes through on login/register/getCurrentUser. */
val currentUserState: StateFlow<ApiResult<User>> = _currentUserState val currentUserState: StateFlow<ApiResult<User>> =
dataManager.currentUser
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle) private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle)
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
@@ -149,19 +158,18 @@ class AuthViewModel : ViewModel() {
} }
fun getCurrentUser(forceRefresh: Boolean = false) { fun getCurrentUser(forceRefresh: Boolean = false) {
// Fire the API call; APILayer writes through to DataManager.setCurrentUser
// on success. [currentUserState] is a derived flow so it re-emits
// automatically. No local state mutation needed.
viewModelScope.launch { viewModelScope.launch {
_currentUserState.value = ApiResult.Loading APILayer.getCurrentUser(forceRefresh = forceRefresh)
val result = APILayer.getCurrentUser(forceRefresh = forceRefresh)
_currentUserState.value = when (result) {
is ApiResult.Success -> ApiResult.Success(result.data)
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
} }
} }
/** No-op — [currentUserState] is derived from DataManager and can't be
* locally reset. To clear, call [DataManager.setCurrentUser] with null. */
fun resetCurrentUserState() { fun resetCurrentUserState() {
_currentUserState.value = ApiResult.Idle // intentionally empty
} }
fun forgotPassword(email: String) { fun forgotPassword(email: String) {

View File

@@ -2,20 +2,55 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.models.* import com.tt.honeyDue.models.*
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ContractorViewModel : ViewModel() { /**
* ContractorViewModel — read-state derived from [IDataManager].
*
* Reads ([contractorsState], [contractorDetailState]) reactively mirror
* [IDataManager.contractors] / [IDataManager.contractorDetail] so the
* VM reflects any DataManager update instantly (API success write,
* fixture seed, etc.). Mutations (create/update/delete/toggleFavorite)
* remain owned by the VM as one-shot [ApiResult] fields.
*/
class ContractorViewModel(
private val dataManager: IDataManager = DataManager,
) : ViewModel() {
private val _contractorsState = MutableStateFlow<ApiResult<List<ContractorSummary>>>(ApiResult.Idle) // ---------- Read state ----------
val contractorsState: StateFlow<ApiResult<List<ContractorSummary>>> = _contractorsState
private val _contractorDetailState = MutableStateFlow<ApiResult<Contractor>>(ApiResult.Idle) val contractorsState: StateFlow<ApiResult<List<ContractorSummary>>> =
val contractorDetailState: StateFlow<ApiResult<Contractor>> = _contractorDetailState dataManager.contractors
.map { list -> if (list.isNotEmpty()) ApiResult.Success(list) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
private val _selectedContractorId = MutableStateFlow<Int?>(null)
val contractorDetailState: StateFlow<ApiResult<Contractor>> =
combine(_selectedContractorId, dataManager.contractorDetail) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
// ---------- Loading / error ----------
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _loadError = MutableStateFlow<String?>(null)
val loadError: StateFlow<String?> = _loadError
// ---------- Mutation-feedback ----------
private val _createState = MutableStateFlow<ApiResult<Contractor>>(ApiResult.Idle) private val _createState = MutableStateFlow<ApiResult<Contractor>>(ApiResult.Idle)
val createState: StateFlow<ApiResult<Contractor>> = _createState val createState: StateFlow<ApiResult<Contractor>> = _createState
@@ -29,32 +64,45 @@ class ContractorViewModel : ViewModel() {
private val _toggleFavoriteState = MutableStateFlow<ApiResult<Contractor>>(ApiResult.Idle) private val _toggleFavoriteState = MutableStateFlow<ApiResult<Contractor>>(ApiResult.Idle)
val toggleFavoriteState: StateFlow<ApiResult<Contractor>> = _toggleFavoriteState val toggleFavoriteState: StateFlow<ApiResult<Contractor>> = _toggleFavoriteState
// ---------- Loaders (write-through to DataManager) ----------
fun loadContractors( fun loadContractors(
specialty: String? = null, specialty: String? = null,
isFavorite: Boolean? = null, isFavorite: Boolean? = null,
isActive: Boolean? = null, isActive: Boolean? = null,
search: String? = null, search: String? = null,
forceRefresh: Boolean = false forceRefresh: Boolean = false,
) { ) {
viewModelScope.launch { viewModelScope.launch {
_contractorsState.value = ApiResult.Loading _isLoading.value = true
_contractorsState.value = APILayer.getContractors( _loadError.value = null
_loadError.value = (APILayer.getContractors(
specialty = specialty, specialty = specialty,
isFavorite = isFavorite, isFavorite = isFavorite,
isActive = isActive, isActive = isActive,
search = search, search = search,
forceRefresh = forceRefresh forceRefresh = forceRefresh,
) ) as? ApiResult.Error)?.message
_isLoading.value = false
} }
} }
fun loadContractorDetail(id: Int) { fun loadContractorDetail(id: Int) {
viewModelScope.launch { viewModelScope.launch {
_contractorDetailState.value = ApiResult.Loading _selectedContractorId.value = id
_contractorDetailState.value = APILayer.getContractor(id) _isLoading.value = true
_loadError.value = null
_loadError.value = (APILayer.getContractor(id) as? ApiResult.Error)?.message
_isLoading.value = false
} }
} }
fun selectContractor(id: Int?) {
_selectedContractorId.value = id
}
// ---------- Mutations ----------
fun createContractor(request: ContractorCreateRequest) { fun createContractor(request: ContractorCreateRequest) {
viewModelScope.launch { viewModelScope.launch {
_createState.value = ApiResult.Loading _createState.value = ApiResult.Loading
@@ -83,19 +131,8 @@ class ContractorViewModel : ViewModel() {
} }
} }
fun resetCreateState() { fun resetCreateState() { _createState.value = ApiResult.Idle }
_createState.value = ApiResult.Idle fun resetUpdateState() { _updateState.value = ApiResult.Idle }
} fun resetDeleteState() { _deleteState.value = ApiResult.Idle }
fun resetToggleFavoriteState() { _toggleFavoriteState.value = ApiResult.Idle }
fun resetUpdateState() {
_updateState.value = ApiResult.Idle
}
fun resetDeleteState() {
_deleteState.value = ApiResult.Idle
}
fun resetToggleFavoriteState() {
_toggleFavoriteState.value = ApiResult.Idle
}
} }

View File

@@ -2,21 +2,60 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.models.* import com.tt.honeyDue.models.*
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.util.ImageCompressor import com.tt.honeyDue.util.ImageCompressor
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class DocumentViewModel : ViewModel() { /**
* DocumentViewModel — read-state derived from [IDataManager].
*
* [documentsState] and [documentDetailState] are reactive projections of
* [IDataManager.documents] / [IDataManager.documentDetail]. Mutation-
* feedback fields (create/update/delete/download/deleteImage/uploadImage)
* remain independent [MutableStateFlow]s — they're one-shot results, not
* cached data.
*/
class DocumentViewModel(
private val dataManager: IDataManager = DataManager,
) : ViewModel() {
private val _documentsState = MutableStateFlow<ApiResult<List<Document>>>(ApiResult.Idle) // ---------- Read state ----------
val documentsState: StateFlow<ApiResult<List<Document>>> = _documentsState
private val _documentDetailState = MutableStateFlow<ApiResult<Document>>(ApiResult.Idle) val documentsState: StateFlow<ApiResult<List<Document>>> =
val documentDetailState: StateFlow<ApiResult<Document>> = _documentDetailState dataManager.documents
.map { list -> if (list.isNotEmpty()) ApiResult.Success(list) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
private val _selectedDocumentId = MutableStateFlow<Int?>(null)
val documentDetailState: StateFlow<ApiResult<Document>> =
combine(_selectedDocumentId, dataManager.documentDetail) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
fun selectDocument(id: Int?) {
_selectedDocumentId.value = id
}
// ---------- Loading / error ----------
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _loadError = MutableStateFlow<String?>(null)
val loadError: StateFlow<String?> = _loadError
// ---------- Mutation-feedback ----------
private val _createState = MutableStateFlow<ApiResult<Document>>(ApiResult.Idle) private val _createState = MutableStateFlow<ApiResult<Document>>(ApiResult.Idle)
val createState: StateFlow<ApiResult<Document>> = _createState val createState: StateFlow<ApiResult<Document>> = _createState
@@ -36,6 +75,8 @@ class DocumentViewModel : ViewModel() {
private val _uploadImageState = MutableStateFlow<ApiResult<Document>>(ApiResult.Idle) private val _uploadImageState = MutableStateFlow<ApiResult<Document>>(ApiResult.Idle)
val uploadImageState: StateFlow<ApiResult<Document>> = _uploadImageState val uploadImageState: StateFlow<ApiResult<Document>> = _uploadImageState
// ---------- Loaders (write-through to DataManager) ----------
fun loadDocuments( fun loadDocuments(
residenceId: Int? = null, residenceId: Int? = null,
documentType: String? = null, documentType: String? = null,
@@ -48,8 +89,9 @@ class DocumentViewModel : ViewModel() {
forceRefresh: Boolean = false forceRefresh: Boolean = false
) { ) {
viewModelScope.launch { viewModelScope.launch {
_documentsState.value = ApiResult.Loading _isLoading.value = true
_documentsState.value = APILayer.getDocuments( _loadError.value = null
_loadError.value = (APILayer.getDocuments(
residenceId = residenceId, residenceId = residenceId,
documentType = documentType, documentType = documentType,
category = category, category = category,
@@ -59,7 +101,8 @@ class DocumentViewModel : ViewModel() {
tags = tags, tags = tags,
search = search, search = search,
forceRefresh = forceRefresh forceRefresh = forceRefresh
) ) as? ApiResult.Error)?.message
_isLoading.value = false
} }
} }
@@ -72,18 +115,23 @@ class DocumentViewModel : ViewModel() {
forceRefresh: Boolean = false forceRefresh: Boolean = false
) { ) {
viewModelScope.launch { viewModelScope.launch {
_documentsState.value = ApiResult.Loading _isLoading.value = true
_documentsState.value = APILayer.getDocuments( _loadError.value = null
_loadError.value = (APILayer.getDocuments(
residenceId = residenceId, residenceId = residenceId,
forceRefresh = forceRefresh forceRefresh = forceRefresh
) ) as? ApiResult.Error)?.message
_isLoading.value = false
} }
} }
fun loadDocumentDetail(id: Int) { fun loadDocumentDetail(id: Int) {
viewModelScope.launch { viewModelScope.launch {
_documentDetailState.value = ApiResult.Loading _selectedDocumentId.value = id
_documentDetailState.value = APILayer.getDocument(id) _isLoading.value = true
_loadError.value = null
_loadError.value = (APILayer.getDocument(id) as? ApiResult.Error)?.message
_isLoading.value = false
} }
} }

View File

@@ -3,93 +3,88 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.models.* import com.tt.honeyDue.models.*
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* ViewModel for lookup data. * LookupsViewModel — already the template for reactive DataManager
* Now uses DataManager as the single source of truth for all lookups. * derivation. Extended to accept [IDataManager] as a constructor param
* Lookups are loaded once via APILayer.initializeLookups() after login. * so test doubles can be injected identically to every other VM.
*
* Direct StateFlow exposure (no wrapping) for the happy path:
* screens observing [residenceTypes] etc. see the live [IDataManager]
* value instantly.
*
* Legacy `*State: StateFlow<ApiResult<T>>` fields are kept for screens
* still coded against the `ApiResult` pattern — they're now derived
* from DataManager via `.map + .stateIn` so they emit `Success` as
* soon as data is present, bypassing the old `_xxxState.value =`
* write pattern.
*/ */
class LookupsViewModel : ViewModel() { class LookupsViewModel(
private val dataManager: IDataManager = DataManager,
) : ViewModel() {
// Expose DataManager's lookup StateFlows directly // ---------- Direct exposure (preferred) ----------
val residenceTypes: StateFlow<List<ResidenceType>> = DataManager.residenceTypes
val taskFrequencies: StateFlow<List<TaskFrequency>> = DataManager.taskFrequencies
val taskPriorities: StateFlow<List<TaskPriority>> = DataManager.taskPriorities
val taskCategories: StateFlow<List<TaskCategory>> = DataManager.taskCategories
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = DataManager.contractorSpecialties
// Keep legacy state flows for compatibility during migration val residenceTypes: StateFlow<List<ResidenceType>> = dataManager.residenceTypes
private val _residenceTypesState = MutableStateFlow<ApiResult<List<ResidenceType>>>(ApiResult.Idle) val taskFrequencies: StateFlow<List<TaskFrequency>> = dataManager.taskFrequencies
val residenceTypesState: StateFlow<ApiResult<List<ResidenceType>>> = _residenceTypesState val taskPriorities: StateFlow<List<TaskPriority>> = dataManager.taskPriorities
val taskCategories: StateFlow<List<TaskCategory>> = dataManager.taskCategories
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = dataManager.contractorSpecialties
private val _taskFrequenciesState = MutableStateFlow<ApiResult<List<TaskFrequency>>>(ApiResult.Idle) // ---------- ApiResult-wrapped projections (legacy — derived) ----------
val taskFrequenciesState: StateFlow<ApiResult<List<TaskFrequency>>> = _taskFrequenciesState
private val _taskPrioritiesState = MutableStateFlow<ApiResult<List<TaskPriority>>>(ApiResult.Idle) val residenceTypesState: StateFlow<ApiResult<List<ResidenceType>>> =
val taskPrioritiesState: StateFlow<ApiResult<List<TaskPriority>>> = _taskPrioritiesState dataManager.residenceTypes
.map { if (it.isNotEmpty()) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
private val _taskCategoriesState = MutableStateFlow<ApiResult<List<TaskCategory>>>(ApiResult.Idle) val taskFrequenciesState: StateFlow<ApiResult<List<TaskFrequency>>> =
val taskCategoriesState: StateFlow<ApiResult<List<TaskCategory>>> = _taskCategoriesState dataManager.taskFrequencies
.map { if (it.isNotEmpty()) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
val taskPrioritiesState: StateFlow<ApiResult<List<TaskPriority>>> =
dataManager.taskPriorities
.map { if (it.isNotEmpty()) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
val taskCategoriesState: StateFlow<ApiResult<List<TaskCategory>>> =
dataManager.taskCategories
.map { if (it.isNotEmpty()) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
// ---------- Load methods (write-through) ----------
fun loadResidenceTypes() { fun loadResidenceTypes() {
viewModelScope.launch { if (dataManager.residenceTypes.value.isNotEmpty()) return
val cached = DataManager.residenceTypes.value viewModelScope.launch { APILayer.getResidenceTypes() }
if (cached.isNotEmpty()) {
_residenceTypesState.value = ApiResult.Success(cached)
return@launch
}
_residenceTypesState.value = ApiResult.Loading
val result = APILayer.getResidenceTypes()
_residenceTypesState.value = result
}
} }
fun loadTaskFrequencies() { fun loadTaskFrequencies() {
viewModelScope.launch { if (dataManager.taskFrequencies.value.isNotEmpty()) return
val cached = DataManager.taskFrequencies.value viewModelScope.launch { APILayer.getTaskFrequencies() }
if (cached.isNotEmpty()) {
_taskFrequenciesState.value = ApiResult.Success(cached)
return@launch
}
_taskFrequenciesState.value = ApiResult.Loading
val result = APILayer.getTaskFrequencies()
_taskFrequenciesState.value = result
}
} }
fun loadTaskPriorities() { fun loadTaskPriorities() {
viewModelScope.launch { if (dataManager.taskPriorities.value.isNotEmpty()) return
val cached = DataManager.taskPriorities.value viewModelScope.launch { APILayer.getTaskPriorities() }
if (cached.isNotEmpty()) {
_taskPrioritiesState.value = ApiResult.Success(cached)
return@launch
}
_taskPrioritiesState.value = ApiResult.Loading
val result = APILayer.getTaskPriorities()
_taskPrioritiesState.value = result
}
} }
fun loadTaskCategories() { fun loadTaskCategories() {
viewModelScope.launch { if (dataManager.taskCategories.value.isNotEmpty()) return
val cached = DataManager.taskCategories.value viewModelScope.launch { APILayer.getTaskCategories() }
if (cached.isNotEmpty()) {
_taskCategoriesState.value = ApiResult.Success(cached)
return@launch
}
_taskCategoriesState.value = ApiResult.Loading
val result = APILayer.getTaskCategories()
_taskCategoriesState.value = result
}
} }
// Load all lookups at once
fun loadAllLookups() { fun loadAllLookups() {
loadResidenceTypes() loadResidenceTypes()
loadTaskFrequencies() loadTaskFrequencies()

View File

@@ -2,13 +2,18 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.models.NotificationPreference import com.tt.honeyDue.models.NotificationPreference
import com.tt.honeyDue.models.UpdateNotificationPreferencesRequest import com.tt.honeyDue.models.UpdateNotificationPreferencesRequest
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -73,10 +78,16 @@ class NotificationCategoriesController(
* alongside the new per-category local toggles driven by * alongside the new per-category local toggles driven by
* [NotificationCategoriesController]. * [NotificationCategoriesController].
*/ */
class NotificationPreferencesViewModel : ViewModel() { class NotificationPreferencesViewModel(
private val dataManager: IDataManager = DataManager,
) : ViewModel() {
private val _preferencesState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle) /** Server-backed preferences — derived from [IDataManager.notificationPreferences].
val preferencesState: StateFlow<ApiResult<NotificationPreference>> = _preferencesState.asStateFlow() * APILayer.getNotificationPreferences / updateNotificationPreferences write through. */
val preferencesState: StateFlow<ApiResult<NotificationPreference>> =
dataManager.notificationPreferences
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
private val _updateState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle) private val _updateState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
val updateState: StateFlow<ApiResult<NotificationPreference>> = _updateState.asStateFlow() val updateState: StateFlow<ApiResult<NotificationPreference>> = _updateState.asStateFlow()
@@ -92,15 +103,9 @@ class NotificationPreferencesViewModel : ViewModel() {
val categoryState: StateFlow<Map<String, Boolean>> = _categoryState.asStateFlow() val categoryState: StateFlow<Map<String, Boolean>> = _categoryState.asStateFlow()
fun loadPreferences() { fun loadPreferences() {
viewModelScope.launch { // Fire the API call; APILayer writes to DataManager.setNotificationPreferences
_preferencesState.value = ApiResult.Loading // on success and [preferencesState] re-emits automatically.
val result = APILayer.getNotificationPreferences() viewModelScope.launch { APILayer.getNotificationPreferences() }
_preferencesState.value = when (result) {
is ApiResult.Success -> ApiResult.Success(result.data)
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
}
} }
fun updatePreference( fun updatePreference(
@@ -133,15 +138,10 @@ class NotificationPreferencesViewModel : ViewModel() {
warrantyExpiringHour = warrantyExpiringHour, warrantyExpiringHour = warrantyExpiringHour,
dailyDigestHour = dailyDigestHour, dailyDigestHour = dailyDigestHour,
) )
val result = APILayer.updateNotificationPreferences(request) // APILayer.updateNotificationPreferences writes through to
_updateState.value = when (result) { // DataManager.setNotificationPreferences on success, so
is ApiResult.Success -> { // [preferencesState] re-emits automatically.
_preferencesState.value = ApiResult.Success(result.data) _updateState.value = APILayer.updateNotificationPreferences(request)
ApiResult.Success(result.data)
}
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
} }
} }

View File

@@ -2,25 +2,79 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.models.ContractorSummary
import com.tt.honeyDue.models.MyResidencesResponse
import com.tt.honeyDue.models.Residence import com.tt.honeyDue.models.Residence
import com.tt.honeyDue.models.ResidenceCreateRequest import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.TotalSummary
import com.tt.honeyDue.models.MyResidencesResponse
import com.tt.honeyDue.models.TaskColumnsResponse import com.tt.honeyDue.models.TaskColumnsResponse
import com.tt.honeyDue.models.ContractorSummary import com.tt.honeyDue.models.TotalSummary
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.network.APILayer import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ResidenceViewModel : ViewModel() { /**
* ResidenceViewModel — read-state derived from [IDataManager].
*
* All list/detail reads (`residencesState`, `myResidencesState`,
* `summaryState`, `residenceTasksState`, `residenceContractorsState`)
* are reactive projections of [IDataManager] StateFlows. Mutation
* feedback (create/update/delete/join/cancel/uncancel/updateTask/
* generateReport) remains owned by the VM as one-shot [ApiResult]
* fields — they track API operation outcomes, not cached data.
*/
class ResidenceViewModel(
private val dataManager: IDataManager = DataManager,
) : ViewModel() {
private val _residencesState = MutableStateFlow<ApiResult<List<Residence>>>(ApiResult.Idle) // ---------- Read state (derived from DataManager) ----------
val residencesState: StateFlow<ApiResult<List<Residence>>> = _residencesState
private val _summaryState = MutableStateFlow<ApiResult<TotalSummary>>(ApiResult.Idle) val residencesState: StateFlow<ApiResult<List<Residence>>> =
val summaryState: StateFlow<ApiResult<TotalSummary>> = _summaryState dataManager.residences
.map { list -> if (list.isNotEmpty()) ApiResult.Success(list) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> =
dataManager.myResidences
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
val summaryState: StateFlow<ApiResult<TotalSummary>> =
dataManager.totalSummary
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
/** Drives the residence-scoped projections. */
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
combine(_selectedResidenceId, dataManager.tasksByResidence) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
val residenceContractorsState: StateFlow<ApiResult<List<ContractorSummary>>> =
combine(_selectedResidenceId, dataManager.contractorsByResidence) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
// ---------- Loading / error feedback ----------
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _loadError = MutableStateFlow<String?>(null)
val loadError: StateFlow<String?> = _loadError
// ---------- Mutation-feedback (one-shot, owned by VM) ----------
private val _createResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle) private val _createResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState
@@ -28,11 +82,11 @@ class ResidenceViewModel : ViewModel() {
private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle) private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
private val _residenceTasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle) private val _deleteResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _residenceTasksState val deleteResidenceState: StateFlow<ApiResult<Unit>> = _deleteResidenceState
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle) private val _joinResidenceState = MutableStateFlow<ApiResult<com.tt.honeyDue.models.JoinResidenceResponse>>(ApiResult.Idle)
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState val joinResidenceState: StateFlow<ApiResult<com.tt.honeyDue.models.JoinResidenceResponse>> = _joinResidenceState
private val _cancelTaskState = MutableStateFlow<ApiResult<com.tt.honeyDue.models.CustomTask>>(ApiResult.Idle) private val _cancelTaskState = MutableStateFlow<ApiResult<com.tt.honeyDue.models.CustomTask>>(ApiResult.Idle)
val cancelTaskState: StateFlow<ApiResult<com.tt.honeyDue.models.CustomTask>> = _cancelTaskState val cancelTaskState: StateFlow<ApiResult<com.tt.honeyDue.models.CustomTask>> = _cancelTaskState
@@ -46,37 +100,77 @@ class ResidenceViewModel : ViewModel() {
private val _generateReportState = MutableStateFlow<ApiResult<com.tt.honeyDue.network.GenerateReportResponse>>(ApiResult.Idle) private val _generateReportState = MutableStateFlow<ApiResult<com.tt.honeyDue.network.GenerateReportResponse>>(ApiResult.Idle)
val generateReportState: StateFlow<ApiResult<com.tt.honeyDue.network.GenerateReportResponse>> = _generateReportState val generateReportState: StateFlow<ApiResult<com.tt.honeyDue.network.GenerateReportResponse>> = _generateReportState
private val _deleteResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle) // ---------- Projection selectors ----------
val deleteResidenceState: StateFlow<ApiResult<Unit>> = _deleteResidenceState
private val _residenceContractorsState = MutableStateFlow<ApiResult<List<ContractorSummary>>>(ApiResult.Idle) fun selectResidence(residenceId: Int?) {
val residenceContractorsState: StateFlow<ApiResult<List<ContractorSummary>>> = _residenceContractorsState _selectedResidenceId.value = residenceId
}
// ---------- Load methods (write-through to DataManager) ----------
/**
* Load residences from cache. If cache is empty or force refresh is requested,
* fetch from API and update cache.
*/
fun loadResidences(forceRefresh: Boolean = false) { fun loadResidences(forceRefresh: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
_residencesState.value = ApiResult.Loading _isLoading.value = true
_residencesState.value = APILayer.getResidences(forceRefresh = forceRefresh) _loadError.value = null
_loadError.value = (APILayer.getResidences(forceRefresh = forceRefresh) as? ApiResult.Error)?.message
_isLoading.value = false
} }
} }
fun loadSummary(forceRefresh: Boolean = false) { fun loadSummary(forceRefresh: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
_summaryState.value = ApiResult.Loading _isLoading.value = true
_summaryState.value = APILayer.getSummary(forceRefresh = forceRefresh) _loadError.value = null
_loadError.value = (APILayer.getSummary(forceRefresh = forceRefresh) as? ApiResult.Error)?.message
_isLoading.value = false
} }
} }
fun getResidence(id: Int, onResult: (ApiResult<Residence>) -> Unit) { fun getResidence(id: Int, onResult: (ApiResult<Residence>) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val result = APILayer.getResidence(id) onResult(APILayer.getResidence(id))
onResult(result)
} }
} }
fun loadMyResidences(forceRefresh: Boolean = false) {
viewModelScope.launch {
_isLoading.value = true
_loadError.value = null
_loadError.value = (APILayer.getMyResidences(forceRefresh = forceRefresh) as? ApiResult.Error)?.message
_isLoading.value = false
}
}
fun loadResidenceTasks(residenceId: Int) {
viewModelScope.launch {
_selectedResidenceId.value = residenceId
_isLoading.value = true
_loadError.value = null
_loadError.value = (APILayer.getTasksByResidence(residenceId) as? ApiResult.Error)?.message
_isLoading.value = false
}
}
fun loadResidenceContractors(residenceId: Int) {
viewModelScope.launch {
_selectedResidenceId.value = residenceId
_isLoading.value = true
_loadError.value = null
_loadError.value = (APILayer.getContractorsByResidence(residenceId) as? ApiResult.Error)?.message
_isLoading.value = false
}
}
fun resetResidenceTasksState() {
_selectedResidenceId.value = null
}
fun resetResidenceContractorsState() {
_selectedResidenceId.value = null
}
// ---------- Mutations ----------
fun createResidence(request: ResidenceCreateRequest) { fun createResidence(request: ResidenceCreateRequest) {
viewModelScope.launch { viewModelScope.launch {
_createResidenceState.value = ApiResult.Loading _createResidenceState.value = ApiResult.Loading
@@ -84,17 +178,6 @@ class ResidenceViewModel : ViewModel() {
} }
} }
fun resetResidenceTasksState() {
_residenceTasksState.value = ApiResult.Idle
}
fun loadResidenceTasks(residenceId: Int) {
viewModelScope.launch {
_residenceTasksState.value = ApiResult.Loading
_residenceTasksState.value = APILayer.getTasksByResidence(residenceId)
}
}
fun updateResidence(residenceId: Int, request: ResidenceCreateRequest) { fun updateResidence(residenceId: Int, request: ResidenceCreateRequest) {
viewModelScope.launch { viewModelScope.launch {
_updateResidenceState.value = ApiResult.Loading _updateResidenceState.value = ApiResult.Loading
@@ -102,20 +185,8 @@ class ResidenceViewModel : ViewModel() {
} }
} }
fun resetCreateState() { fun resetCreateState() { _createResidenceState.value = ApiResult.Idle }
_createResidenceState.value = ApiResult.Idle fun resetUpdateState() { _updateResidenceState.value = ApiResult.Idle }
}
fun resetUpdateState() {
_updateResidenceState.value = ApiResult.Idle
}
fun loadMyResidences(forceRefresh: Boolean = false) {
viewModelScope.launch {
_myResidencesState.value = ApiResult.Loading
_myResidencesState.value = APILayer.getMyResidences(forceRefresh = forceRefresh)
}
}
fun cancelTask(taskId: Int) { fun cancelTask(taskId: Int) {
viewModelScope.launch { viewModelScope.launch {
@@ -138,17 +209,9 @@ class ResidenceViewModel : ViewModel() {
} }
} }
fun resetCancelTaskState() { fun resetCancelTaskState() { _cancelTaskState.value = ApiResult.Idle }
_cancelTaskState.value = ApiResult.Idle fun resetUncancelTaskState() { _uncancelTaskState.value = ApiResult.Idle }
} fun resetUpdateTaskState() { _updateTaskState.value = ApiResult.Idle }
fun resetUncancelTaskState() {
_uncancelTaskState.value = ApiResult.Idle
}
fun resetUpdateTaskState() {
_updateTaskState.value = ApiResult.Idle
}
fun generateTasksReport(residenceId: Int, email: String? = null) { fun generateTasksReport(residenceId: Int, email: String? = null) {
viewModelScope.launch { viewModelScope.launch {
@@ -157,9 +220,7 @@ class ResidenceViewModel : ViewModel() {
} }
} }
fun resetGenerateReportState() { fun resetGenerateReportState() { _generateReportState.value = ApiResult.Idle }
_generateReportState.value = ApiResult.Idle
}
fun deleteResidence(residenceId: Int) { fun deleteResidence(residenceId: Int) {
viewModelScope.launch { viewModelScope.launch {
@@ -168,12 +229,7 @@ class ResidenceViewModel : ViewModel() {
} }
} }
fun resetDeleteResidenceState() { fun resetDeleteResidenceState() { _deleteResidenceState.value = ApiResult.Idle }
_deleteResidenceState.value = ApiResult.Idle
}
private val _joinResidenceState = MutableStateFlow<ApiResult<com.tt.honeyDue.models.JoinResidenceResponse>>(ApiResult.Idle)
val joinResidenceState: StateFlow<ApiResult<com.tt.honeyDue.models.JoinResidenceResponse>> = _joinResidenceState
fun joinWithCode(code: String) { fun joinWithCode(code: String) {
viewModelScope.launch { viewModelScope.launch {
@@ -182,18 +238,5 @@ class ResidenceViewModel : ViewModel() {
} }
} }
fun resetJoinResidenceState() { fun resetJoinResidenceState() { _joinResidenceState.value = ApiResult.Idle }
_joinResidenceState.value = ApiResult.Idle
}
fun loadResidenceContractors(residenceId: Int) {
viewModelScope.launch {
_residenceContractorsState.value = ApiResult.Loading
_residenceContractorsState.value = APILayer.getContractorsByResidence(residenceId)
}
}
fun resetResidenceContractorsState() {
_residenceContractorsState.value = ApiResult.Idle
}
} }

View File

@@ -2,175 +2,184 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tt.honeyDue.models.TaskColumnsResponse import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.models.CustomTask import com.tt.honeyDue.models.CustomTask
import com.tt.honeyDue.models.TaskCreateRequest import com.tt.honeyDue.models.TaskColumnsResponse
import com.tt.honeyDue.models.TaskCompletionResponse import com.tt.honeyDue.models.TaskCompletionResponse
import com.tt.honeyDue.network.ApiResult import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.network.APILayer import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class TaskViewModel : ViewModel() { /**
* TaskViewModel — derives all read-state from [IDataManager].
*
* The VM no longer owns `_tasksState` / `_tasksByResidenceState` /
* `_taskCompletionsState` `MutableStateFlow` fields. Instead:
*
* * [tasksState] is a derived flow over [IDataManager.allTasks] — whenever
* DataManager is updated (APILayer success write, fixture seed, etc.),
* the VM re-emits automatically. Screens rendering against the fixture
* in snapshot tests see populated data immediately.
* * [tasksByResidenceState] is parameterised by [selectedResidenceId]
* which callers set via [selectResidence]. The projection pulls the
* current selected-residence tasks out of [IDataManager.tasksByResidence].
* * [taskCompletionsState] is parameterised by [selectedTaskId].
*
* Loading / error state for the read-states is tracked separately on
* [isLoading] and [loadError]. Mutation-feedback fields ([taskAddNewCustomTaskState])
* remain owned by the VM — they track one-shot mutation outcomes, not
* cached data.
*/
class TaskViewModel(
private val dataManager: IDataManager = DataManager,
) : ViewModel() {
private val _tasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle) // ---------- Read state (derived from DataManager) ----------
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksState
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle) /** All tasks kanban — mirrors [IDataManager.allTasks]. */
val tasksByResidenceState: StateFlow<ApiResult<TaskColumnsResponse>> = _tasksByResidenceState val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
dataManager.allTasks
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
/** Drives the [tasksByResidenceState] projection key. */
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
/** Per-residence kanban — mirrors [IDataManager.tasksByResidence][residenceId]. */
val tasksByResidenceState: StateFlow<ApiResult<TaskColumnsResponse>> =
combine(_selectedResidenceId, dataManager.tasksByResidence) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
/** Drives the [taskCompletionsState] projection key. */
private val _selectedTaskId = MutableStateFlow<Int?>(null)
/** Task completions for the currently selected task — mirrors [IDataManager.taskCompletions]. */
val taskCompletionsState: StateFlow<ApiResult<List<TaskCompletionResponse>>> =
combine(_selectedTaskId, dataManager.taskCompletions) { id, map ->
if (id == null) ApiResult.Idle
else map[id]?.let { ApiResult.Success(it) } ?: ApiResult.Idle
}.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
// ---------- Loading / error feedback ----------
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _loadError = MutableStateFlow<String?>(null)
val loadError: StateFlow<String?> = _loadError
// ---------- Mutation-feedback (independent, one-shot) ----------
private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle) private val _taskAddNewCustomTaskState = MutableStateFlow<ApiResult<CustomTask>>(ApiResult.Idle)
val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState val taskAddNewCustomTaskState: StateFlow<ApiResult<CustomTask>> = _taskAddNewCustomTaskState
private val _taskCompletionsState = MutableStateFlow<ApiResult<List<TaskCompletionResponse>>>(ApiResult.Idle) // ---------- Projection selectors ----------
val taskCompletionsState: StateFlow<ApiResult<List<TaskCompletionResponse>>> = _taskCompletionsState
fun selectResidence(residenceId: Int?) {
_selectedResidenceId.value = residenceId
}
fun selectTask(taskId: Int?) {
_selectedTaskId.value = taskId
}
// ---------- Load methods (write-through to DataManager) ----------
fun loadTasks(forceRefresh: Boolean = false) { fun loadTasks(forceRefresh: Boolean = false) {
println("TaskViewModel: loadTasks called")
viewModelScope.launch { viewModelScope.launch {
_tasksState.value = ApiResult.Loading _isLoading.value = true
_tasksState.value = APILayer.getTasks(forceRefresh = forceRefresh) _loadError.value = null
println("TaskViewModel: loadTasks result: ${_tasksState.value}") val result = APILayer.getTasks(forceRefresh = forceRefresh)
_loadError.value = (result as? ApiResult.Error)?.message
_isLoading.value = false
} }
} }
fun loadTasksByResidence(residenceId: Int, forceRefresh: Boolean = false) { fun loadTasksByResidence(residenceId: Int, forceRefresh: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
_tasksByResidenceState.value = ApiResult.Loading _selectedResidenceId.value = residenceId
_tasksByResidenceState.value = APILayer.getTasksByResidence( _isLoading.value = true
_loadError.value = null
val result = APILayer.getTasksByResidence(
residenceId = residenceId, residenceId = residenceId,
forceRefresh = forceRefresh forceRefresh = forceRefresh,
) )
_loadError.value = (result as? ApiResult.Error)?.message
_isLoading.value = false
} }
} }
fun loadTaskCompletions(taskId: Int) {
viewModelScope.launch {
_selectedTaskId.value = taskId
_isLoading.value = true
_loadError.value = null
val result = APILayer.getTaskCompletions(taskId)
_loadError.value = (result as? ApiResult.Error)?.message
_isLoading.value = false
}
}
fun resetTaskCompletionsState() {
_selectedTaskId.value = null
}
// ---------- Mutations ----------
fun createNewTask(request: TaskCreateRequest) { fun createNewTask(request: TaskCreateRequest) {
println("TaskViewModel: createNewTask called with $request")
viewModelScope.launch { viewModelScope.launch {
println("TaskViewModel: Setting state to Loading")
_taskAddNewCustomTaskState.value = ApiResult.Loading _taskAddNewCustomTaskState.value = ApiResult.Loading
val result = APILayer.createTask(request) _taskAddNewCustomTaskState.value = APILayer.createTask(request)
println("TaskViewModel: API result: $result")
_taskAddNewCustomTaskState.value = result
} }
} }
fun resetAddTaskState() { fun resetAddTaskState() {
_taskAddNewCustomTaskState.value = ApiResult.Idle _taskAddNewCustomTaskState.value = ApiResult.Idle
} }
fun updateTask(taskId: Int, request: TaskCreateRequest, onComplete: (Boolean) -> Unit) { fun updateTask(taskId: Int, request: TaskCreateRequest, onComplete: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
when (val result = APILayer.updateTask(taskId, request)) { onComplete(APILayer.updateTask(taskId, request) is ApiResult.Success)
is ApiResult.Success -> {
onComplete(true)
}
is ApiResult.Error -> {
onComplete(false)
}
else -> {
onComplete(false)
}
}
} }
} }
fun cancelTask(taskId: Int, onComplete: (Boolean) -> Unit) { fun cancelTask(taskId: Int, onComplete: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
when (val result = APILayer.cancelTask(taskId)) { onComplete(APILayer.cancelTask(taskId) is ApiResult.Success)
is ApiResult.Success -> {
onComplete(true)
}
is ApiResult.Error -> {
onComplete(false)
}
else -> {
onComplete(false)
}
}
} }
} }
fun uncancelTask(taskId: Int, onComplete: (Boolean) -> Unit) { fun uncancelTask(taskId: Int, onComplete: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
when (val result = APILayer.uncancelTask(taskId)) { onComplete(APILayer.uncancelTask(taskId) is ApiResult.Success)
is ApiResult.Success -> {
onComplete(true)
}
is ApiResult.Error -> {
onComplete(false)
}
else -> {
onComplete(false)
}
}
} }
} }
fun markInProgress(taskId: Int, onComplete: (Boolean) -> Unit) { fun markInProgress(taskId: Int, onComplete: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
when (val result = APILayer.markInProgress(taskId)) { onComplete(APILayer.markInProgress(taskId) is ApiResult.Success)
is ApiResult.Success -> {
onComplete(true)
}
is ApiResult.Error -> {
onComplete(false)
}
else -> {
onComplete(false)
}
}
} }
} }
fun archiveTask(taskId: Int, onComplete: (Boolean) -> Unit) { fun archiveTask(taskId: Int, onComplete: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
when (val result = APILayer.archiveTask(taskId)) { onComplete(APILayer.archiveTask(taskId) is ApiResult.Success)
is ApiResult.Success -> {
onComplete(true)
}
is ApiResult.Error -> {
onComplete(false)
}
else -> {
onComplete(false)
}
}
} }
} }
fun unarchiveTask(taskId: Int, onComplete: (Boolean) -> Unit) { fun unarchiveTask(taskId: Int, onComplete: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
when (val result = APILayer.unarchiveTask(taskId)) { onComplete(APILayer.unarchiveTask(taskId) is ApiResult.Success)
is ApiResult.Success -> {
onComplete(true)
}
is ApiResult.Error -> {
onComplete(false)
}
else -> {
onComplete(false)
}
}
} }
} }
/**
* Load completions for a specific task
*/
fun loadTaskCompletions(taskId: Int) {
viewModelScope.launch {
_taskCompletionsState.value = ApiResult.Loading
_taskCompletionsState.value = APILayer.getTaskCompletions(taskId)
}
}
/**
* Reset task completions state
*/
fun resetTaskCompletionsState() {
_taskCompletionsState.value = ApiResult.Idle
}
} }