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
@@ -2,25 +2,79 @@ package com.tt.honeyDue.viewmodel
import androidx.lifecycle.ViewModel
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.ResidenceCreateRequest
import com.tt.honeyDue.models.TotalSummary
import com.tt.honeyDue.models.MyResidencesResponse
import com.tt.honeyDue.models.TaskColumnsResponse
import com.tt.honeyDue.models.ContractorSummary
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.models.TotalSummary
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
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)
val residencesState: StateFlow<ApiResult<List<Residence>>> = _residencesState
// ---------- Read state (derived from DataManager) ----------
private val _summaryState = MutableStateFlow<ApiResult<TotalSummary>>(ApiResult.Idle)
val summaryState: StateFlow<ApiResult<TotalSummary>> = _summaryState
val residencesState: StateFlow<ApiResult<List<Residence>>> =
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)
val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState
@@ -28,11 +82,11 @@ class ResidenceViewModel : ViewModel() {
private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
private val _residenceTasksState = MutableStateFlow<ApiResult<TaskColumnsResponse>>(ApiResult.Idle)
val residenceTasksState: StateFlow<ApiResult<TaskColumnsResponse>> = _residenceTasksState
private val _deleteResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val deleteResidenceState: StateFlow<ApiResult<Unit>> = _deleteResidenceState
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Idle)
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
private val _joinResidenceState = MutableStateFlow<ApiResult<com.tt.honeyDue.models.JoinResidenceResponse>>(ApiResult.Idle)
val joinResidenceState: StateFlow<ApiResult<com.tt.honeyDue.models.JoinResidenceResponse>> = _joinResidenceState
private val _cancelTaskState = MutableStateFlow<ApiResult<com.tt.honeyDue.models.CustomTask>>(ApiResult.Idle)
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)
val generateReportState: StateFlow<ApiResult<com.tt.honeyDue.network.GenerateReportResponse>> = _generateReportState
private val _deleteResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val deleteResidenceState: StateFlow<ApiResult<Unit>> = _deleteResidenceState
// ---------- Projection selectors ----------
private val _residenceContractorsState = MutableStateFlow<ApiResult<List<ContractorSummary>>>(ApiResult.Idle)
val residenceContractorsState: StateFlow<ApiResult<List<ContractorSummary>>> = _residenceContractorsState
fun selectResidence(residenceId: Int?) {
_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) {
viewModelScope.launch {
_residencesState.value = ApiResult.Loading
_residencesState.value = APILayer.getResidences(forceRefresh = forceRefresh)
_isLoading.value = true
_loadError.value = null
_loadError.value = (APILayer.getResidences(forceRefresh = forceRefresh) as? ApiResult.Error)?.message
_isLoading.value = false
}
}
fun loadSummary(forceRefresh: Boolean = false) {
viewModelScope.launch {
_summaryState.value = ApiResult.Loading
_summaryState.value = APILayer.getSummary(forceRefresh = forceRefresh)
_isLoading.value = true
_loadError.value = null
_loadError.value = (APILayer.getSummary(forceRefresh = forceRefresh) as? ApiResult.Error)?.message
_isLoading.value = false
}
}
fun getResidence(id: Int, onResult: (ApiResult<Residence>) -> Unit) {
viewModelScope.launch {
val result = APILayer.getResidence(id)
onResult(result)
onResult(APILayer.getResidence(id))
}
}
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) {
viewModelScope.launch {
_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) {
viewModelScope.launch {
_updateResidenceState.value = ApiResult.Loading
@@ -102,20 +185,8 @@ class ResidenceViewModel : ViewModel() {
}
}
fun resetCreateState() {
_createResidenceState.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 resetCreateState() { _createResidenceState.value = ApiResult.Idle }
fun resetUpdateState() { _updateResidenceState.value = ApiResult.Idle }
fun cancelTask(taskId: Int) {
viewModelScope.launch {
@@ -138,17 +209,9 @@ class ResidenceViewModel : ViewModel() {
}
}
fun resetCancelTaskState() {
_cancelTaskState.value = ApiResult.Idle
}
fun resetUncancelTaskState() {
_uncancelTaskState.value = ApiResult.Idle
}
fun resetUpdateTaskState() {
_updateTaskState.value = ApiResult.Idle
}
fun resetCancelTaskState() { _cancelTaskState.value = ApiResult.Idle }
fun resetUncancelTaskState() { _uncancelTaskState.value = ApiResult.Idle }
fun resetUpdateTaskState() { _updateTaskState.value = ApiResult.Idle }
fun generateTasksReport(residenceId: Int, email: String? = null) {
viewModelScope.launch {
@@ -157,9 +220,7 @@ class ResidenceViewModel : ViewModel() {
}
}
fun resetGenerateReportState() {
_generateReportState.value = ApiResult.Idle
}
fun resetGenerateReportState() { _generateReportState.value = ApiResult.Idle }
fun deleteResidence(residenceId: Int) {
viewModelScope.launch {
@@ -168,12 +229,7 @@ class ResidenceViewModel : ViewModel() {
}
}
fun resetDeleteResidenceState() {
_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 resetDeleteResidenceState() { _deleteResidenceState.value = ApiResult.Idle }
fun joinWithCode(code: String) {
viewModelScope.launch {
@@ -182,18 +238,5 @@ class ResidenceViewModel : ViewModel() {
}
}
fun resetJoinResidenceState() {
_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
}
fun resetJoinResidenceState() { _joinResidenceState.value = ApiResult.Idle }
}