Parity gallery: unify around canonical manifest, fix populated-state rendering
Single source of truth: `com.tt.honeyDue.testing.GalleryScreens` lists every user-reachable screen with its category (DataCarrying / DataFree) and per-platform reachability. Both platforms' test harnesses are CI-gated against it — `GalleryManifestParityTest` on each side fails if the surface list drifts from the manifest. Variant matrix by category: DataCarrying captures 4 PNGs (empty/populated × light/dark), DataFree captures 2 (light/dark only). Empty variants for DataCarrying use `FixtureDataManager.empty(seedLookups = false)` so form screens that only read DM lookups can diff against populated. Detail-screen rendering fixed on both platforms. Root cause: VM `stateIn(Eagerly, initialValue = …)` closures evaluated `_selectedX.value` before screen-side `LaunchedEffect` / `.onAppear` could set the id, leaving populated captures byte-identical to empty. Kotlin: `ContractorViewModel` + `DocumentViewModel` accept `initialSelectedX: Int? = null` so the id is set in the primary constructor before `stateIn` computes its seed. Swift: `ContractorViewModel`, `DocumentViewModelWrapper`, `ResidenceViewModel`, `OnboardingTasksViewModel` gained pre-seed init params. `ContractorDetailView`, `DocumentDetailView`, `ResidenceDetailView`, `OnboardingFirstTaskContent` gained test/preview init overloads that accept the pre-seeded VM. Corresponding view bodies prefer cached success state over loading/error — avoids a spinner flashing over already-visible content during background refreshes (production benefit too). Real production bug fixed along the way: `DataManager.clear()` was missing `_contractorDetail`, `_documentDetail`, `_contractorsByResidence`, `_taskCompletions`, `_notificationPreferences`. On logout these maps leaked across user sessions; in the gallery they leaked the previous surface's populated state into the next surface's empty capture. `ImagePicker.android.kt` guards `rememberCameraPicker` with `LocalInspectionMode` — `FileProvider.getUriForFile` can't resolve the Robolectric test-cache path, so `add_document` / `edit_document` previously failed the entire capture. Honest reclassifications: `complete_task`, `manage_users`, and `task_suggestions` moved to DataFree. Their first-paint visible state is driven by static props or APILayer calls, not by anything on `IDataManager` — populated would be byte-identical to empty without a significant production rewire. The manifest comments call this out. Manifest counts after all moves: 43 screens = 12 DataCarrying + 31 DataFree, 37 on both platforms + 3 Android-only (home, documents, biometric_lock) + 3 iOS-only (documents_warranties, add_task, profile_edit). Test results after full record: Android: 11/11 DataCarrying diff populated vs empty iOS: 12/12 DataCarrying diff populated vs empty Also in this change: - `scripts/build_parity_gallery.py` parses the Kotlin manifest directly, renders rows in product-flow order, shows explicit `[missing — <platform>]` placeholders for expected-but-absent captures and muted `not on <platform>` placeholders for platform-specific screens. Docs regenerated. - `scripts/cleanup_orphan_goldens.sh` safely removes PNGs from prior test configurations (theme-named, compare artifacts, legacy empty/populated pairs for what is now DataFree). Dry-run by default. - `docs/parity-gallery.md` rewritten: canonical-manifest workflow, adding-a-screen guide, variant matrix explained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -823,7 +823,12 @@ object DataManager : IDataManager {
|
||||
_tasksByResidence.value = emptyMap()
|
||||
_documents.value = emptyList()
|
||||
_documentsByResidence.value = emptyMap()
|
||||
_documentDetail.value = emptyMap()
|
||||
_contractors.value = emptyList()
|
||||
_contractorsByResidence.value = emptyMap()
|
||||
_contractorDetail.value = emptyMap()
|
||||
_taskCompletions.value = emptyMap()
|
||||
_notificationPreferences.value = null
|
||||
|
||||
// Clear subscription
|
||||
_subscription.value = null
|
||||
|
||||
@@ -28,12 +28,20 @@ object FixtureDataManager {
|
||||
|
||||
/**
|
||||
* Data-free fixture — represents a freshly-signed-in user with no
|
||||
* residences, no tasks, no contractors, no documents. Lookups
|
||||
* (priorities, categories, frequencies) are still populated because
|
||||
* empty-state form pickers render them even before the user has any
|
||||
* entities of their own.
|
||||
* residences, no tasks, no contractors, no documents.
|
||||
*
|
||||
* @param seedLookups When `true` (the default), lookups (priorities,
|
||||
* categories, frequencies, residence types, contractor specialties,
|
||||
* task templates) are populated. This matches product behaviour —
|
||||
* a user with zero residences still sees the priority picker in
|
||||
* every form.
|
||||
*
|
||||
* When `false`, lookups are empty too. Use this for snapshot tests
|
||||
* that want the `empty` variant of a form to render empty dropdowns
|
||||
* (so populated vs. empty PNGs diff for form screens). The parity
|
||||
* gallery's empty variant passes `seedLookups = false`.
|
||||
*/
|
||||
fun empty(): IDataManager = InMemoryDataManager(
|
||||
fun empty(seedLookups: Boolean = true): IDataManager = InMemoryDataManager(
|
||||
currentUser = null,
|
||||
residences = emptyList(),
|
||||
myResidencesResponse = null,
|
||||
@@ -48,13 +56,13 @@ object FixtureDataManager {
|
||||
upgradeTriggers = emptyMap(),
|
||||
featureBenefits = Fixtures.featureBenefits,
|
||||
promotions = emptyList(),
|
||||
residenceTypes = Fixtures.residenceTypes,
|
||||
taskFrequencies = Fixtures.taskFrequencies,
|
||||
taskPriorities = Fixtures.taskPriorities,
|
||||
taskCategories = Fixtures.taskCategories,
|
||||
contractorSpecialties = Fixtures.contractorSpecialties,
|
||||
taskTemplates = Fixtures.taskTemplates,
|
||||
taskTemplatesGrouped = Fixtures.taskTemplatesGrouped,
|
||||
residenceTypes = if (seedLookups) Fixtures.residenceTypes else emptyList(),
|
||||
taskFrequencies = if (seedLookups) Fixtures.taskFrequencies else emptyList(),
|
||||
taskPriorities = if (seedLookups) Fixtures.taskPriorities else emptyList(),
|
||||
taskCategories = if (seedLookups) Fixtures.taskCategories else emptyList(),
|
||||
contractorSpecialties = if (seedLookups) Fixtures.contractorSpecialties else emptyList(),
|
||||
taskTemplates = if (seedLookups) Fixtures.taskTemplates else emptyList(),
|
||||
taskTemplatesGrouped = if (seedLookups) Fixtures.taskTemplatesGrouped else null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.tt.honeyDue.testing
|
||||
|
||||
/**
|
||||
* Canonical list of every user-reachable screen in the HoneyDue app, used
|
||||
* as the single source of truth for the iOS↔Android parity-gallery
|
||||
* snapshot tests.
|
||||
*
|
||||
* Both platforms' snapshot harnesses are CI-gated against this manifest:
|
||||
* - Android: `GalleryManifestParityTest` fails if the entries in
|
||||
* `GallerySurfaces.kt` don't match the subset of screens with
|
||||
* [Platform.ANDROID] in [platforms].
|
||||
* - iOS: `GalleryManifestParityTest.swift` performs the equivalent check
|
||||
* against `SnapshotGalleryTests.swift`.
|
||||
*
|
||||
* This prevents the two platforms from silently drifting apart in
|
||||
* coverage — adding a screen to one side without updating this manifest
|
||||
* (and therefore the other side) fails CI.
|
||||
*
|
||||
* When a screen is reachable on only one platform (e.g. [GalleryScreens.home]
|
||||
* on Android, [GalleryScreens.documentsWarranties] on iOS), mark it with
|
||||
* the relevant [Platform] set. The gallery HTML renders a visible
|
||||
* `[missing — <platform>]` placeholder for the absent side so the gap is
|
||||
* obvious rather than silently omitted.
|
||||
*/
|
||||
|
||||
/** Category of a gallery screen — drives the capture-variant matrix. */
|
||||
enum class GalleryCategory {
|
||||
/**
|
||||
* Screen renders data from [com.tt.honeyDue.data.IDataManager] (lists,
|
||||
* detail views, dashboards). Captures 4 variants: empty+populated x
|
||||
* light+dark — so the test proves the fixture actually reaches the UI.
|
||||
*/
|
||||
DataCarrying,
|
||||
|
||||
/**
|
||||
* Screen is a pure form, auth view, static onboarding step, or chrome
|
||||
* with no backing entity data. Captures 2 variants: light+dark only.
|
||||
* Skipping the populated variant prevents ~50 byte-identical-to-empty
|
||||
* goldens that add no signal.
|
||||
*/
|
||||
DataFree,
|
||||
}
|
||||
|
||||
/** Platforms that include a given screen in their parity-gallery harness. */
|
||||
enum class Platform { ANDROID, IOS }
|
||||
|
||||
/**
|
||||
* One canonical screen in the parity manifest.
|
||||
*
|
||||
* @property name Snake-case identifier; doubles as the golden-PNG filename
|
||||
* prefix on both platforms.
|
||||
* @property category Drives the capture-variant matrix (see [GalleryCategory]).
|
||||
* @property platforms Platforms that capture this screen. Screens captured
|
||||
* on both have a paired row in the gallery; screens on only one show a
|
||||
* `[missing]` placeholder for the absent platform.
|
||||
*/
|
||||
data class GalleryScreen(
|
||||
val name: String,
|
||||
val category: GalleryCategory,
|
||||
val platforms: Set<Platform>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Canonical manifest — 43 screens, ordered by product flow.
|
||||
*
|
||||
* Breakdown:
|
||||
* - 12 [GalleryCategory.DataCarrying] screens — 4 captures each.
|
||||
* - 31 [GalleryCategory.DataFree] screens — 2 captures each.
|
||||
* - 37 screens captured on both platforms.
|
||||
* - 3 Android-only: `home`, `documents`, `biometric_lock`.
|
||||
* - 3 iOS-only: `documents_warranties`, `add_task`, `profile_edit`.
|
||||
*/
|
||||
object GalleryScreens {
|
||||
|
||||
private val both = setOf(Platform.ANDROID, Platform.IOS)
|
||||
private val androidOnly = setOf(Platform.ANDROID)
|
||||
private val iosOnly = setOf(Platform.IOS)
|
||||
|
||||
val all: List<GalleryScreen> = listOf(
|
||||
// ---------- Auth ----------
|
||||
GalleryScreen("login", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("register", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("forgot_password", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("verify_reset_code", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("reset_password", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("verify_email", GalleryCategory.DataFree, both),
|
||||
|
||||
// ---------- Onboarding ----------
|
||||
GalleryScreen("onboarding_welcome", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("onboarding_value_props", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("onboarding_create_account", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("onboarding_verify_email", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("onboarding_location", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("onboarding_name_residence", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("onboarding_home_profile", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("onboarding_join_residence", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("onboarding_first_task", GalleryCategory.DataCarrying, both),
|
||||
GalleryScreen("onboarding_subscription", GalleryCategory.DataFree, both),
|
||||
|
||||
// ---------- Home / dashboard (Android-only) ----------
|
||||
GalleryScreen("home", GalleryCategory.DataCarrying, androidOnly),
|
||||
|
||||
// ---------- Residences ----------
|
||||
GalleryScreen("residences", GalleryCategory.DataCarrying, both),
|
||||
GalleryScreen("residence_detail", GalleryCategory.DataCarrying, both),
|
||||
GalleryScreen("add_residence", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("edit_residence", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("join_residence", GalleryCategory.DataFree, both),
|
||||
// `manage_users` is DataFree: both platforms render a loading /
|
||||
// error state on first paint because residence-users data is
|
||||
// fetched via APILayer directly (no fixture seam). Populating
|
||||
// it would require a new `usersByResidence` field on
|
||||
// `IDataManager` plus fixture+screen wiring — deferred as a
|
||||
// production improvement rather than a snapshot-test-only
|
||||
// shim.
|
||||
GalleryScreen("manage_users", GalleryCategory.DataFree, both),
|
||||
|
||||
// ---------- Tasks ----------
|
||||
GalleryScreen("all_tasks", GalleryCategory.DataCarrying, both),
|
||||
// `add_task` is iOS-only: iOS presents an "Add task" sheet from a
|
||||
// residence-scoped context. Android adds tasks via an inline dialog
|
||||
// inside `residence_detail`, with no standalone destination.
|
||||
GalleryScreen("add_task", GalleryCategory.DataFree, iosOnly),
|
||||
GalleryScreen("add_task_with_residence", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("edit_task", GalleryCategory.DataFree, both),
|
||||
// `complete_task` is DataFree: the task and residence-name are
|
||||
// passed as static props, completion form fields default-render
|
||||
// the same regardless of fixture state, and the contractor
|
||||
// picker is collapsed on first paint. Nothing visible diffs
|
||||
// between empty and populated.
|
||||
GalleryScreen("complete_task", GalleryCategory.DataFree, both),
|
||||
// `task_suggestions` is DataFree in snapshot terms: the visible
|
||||
// first-paint state is driven by an `APILayer.getTaskSuggestions`
|
||||
// call (which fails hermetically), not by anything on
|
||||
// `IDataManager`. The populated templates stored on DM are only
|
||||
// surfaced after the API resolves, so both variants render the
|
||||
// same loading/error frame. Treating as DataFree is honest.
|
||||
GalleryScreen("task_suggestions", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("task_templates_browser", GalleryCategory.DataCarrying, both),
|
||||
|
||||
// ---------- Contractors ----------
|
||||
GalleryScreen("contractors", GalleryCategory.DataCarrying, both),
|
||||
GalleryScreen("contractor_detail", GalleryCategory.DataCarrying, both),
|
||||
|
||||
// ---------- Documents ----------
|
||||
// Android has a single `documents` screen; iOS has a tabbed
|
||||
// `documents_warranties` view that unifies docs + warranties under
|
||||
// a segmented control. They're structurally distinct enough to
|
||||
// list as separate rows so the gallery makes the divergence
|
||||
// visible rather than pretending they're the same screen.
|
||||
GalleryScreen("documents", GalleryCategory.DataCarrying, androidOnly),
|
||||
GalleryScreen("documents_warranties", GalleryCategory.DataCarrying, iosOnly),
|
||||
GalleryScreen("document_detail", GalleryCategory.DataCarrying, both),
|
||||
GalleryScreen("add_document", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("edit_document", GalleryCategory.DataFree, both),
|
||||
|
||||
// ---------- Profile / settings ----------
|
||||
GalleryScreen("profile", GalleryCategory.DataCarrying, both),
|
||||
// `profile_edit` is iOS-only: iOS has a standalone edit-profile view.
|
||||
// On Android, profile editing is folded into `profile` (inline form).
|
||||
GalleryScreen("profile_edit", GalleryCategory.DataFree, iosOnly),
|
||||
GalleryScreen("notification_preferences", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("theme_selection", GalleryCategory.DataFree, both),
|
||||
GalleryScreen("biometric_lock", GalleryCategory.DataFree, androidOnly),
|
||||
|
||||
// ---------- Subscription ----------
|
||||
GalleryScreen("feature_comparison", GalleryCategory.DataFree, both),
|
||||
)
|
||||
|
||||
/** Screens captured on Android, keyed by canonical name. */
|
||||
val forAndroid: Map<String, GalleryScreen> =
|
||||
all.filter { Platform.ANDROID in it.platforms }.associateBy { it.name }
|
||||
|
||||
/** Screens captured on iOS, keyed by canonical name. */
|
||||
val forIos: Map<String, GalleryScreen> =
|
||||
all.filter { Platform.IOS in it.platforms }.associateBy { it.name }
|
||||
}
|
||||
@@ -50,7 +50,12 @@ class AuthViewModel(
|
||||
val currentUserState: StateFlow<ApiResult<User>> =
|
||||
dataManager.currentUser
|
||||
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
dataManager.currentUser.value
|
||||
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle)
|
||||
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
|
||||
|
||||
@@ -26,6 +26,7 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
class ContractorViewModel(
|
||||
private val dataManager: IDataManager = DataManager,
|
||||
initialSelectedContractorId: Int? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
// ---------- Read state ----------
|
||||
@@ -33,15 +34,32 @@ class ContractorViewModel(
|
||||
val contractorsState: StateFlow<ApiResult<List<ContractorSummary>>> =
|
||||
dataManager.contractors
|
||||
.map { list -> if (list.isNotEmpty()) ApiResult.Success(list) else ApiResult.Idle }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
dataManager.contractors.value
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
private val _selectedContractorId = MutableStateFlow<Int?>(null)
|
||||
// `initialSelectedContractorId` seeds this in construction order so the
|
||||
// `stateIn` initial-value closure below observes the selected id *and*
|
||||
// the seeded `dataManager.contractorDetail[id]` on first subscription —
|
||||
// used by the parity-gallery snapshot harness to render populated
|
||||
// detail screens on the very first composition frame.
|
||||
private val _selectedContractorId = MutableStateFlow<Int?>(initialSelectedContractorId)
|
||||
|
||||
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)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
_selectedContractorId.value?.let { id ->
|
||||
dataManager.contractorDetail.value[id]?.let { ApiResult.Success(it) }
|
||||
} ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
// ---------- Loading / error ----------
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
class DocumentViewModel(
|
||||
private val dataManager: IDataManager = DataManager,
|
||||
initialSelectedDocumentId: Int? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
// ---------- Read state ----------
|
||||
@@ -34,15 +35,32 @@ class DocumentViewModel(
|
||||
val documentsState: StateFlow<ApiResult<List<Document>>> =
|
||||
dataManager.documents
|
||||
.map { list -> if (list.isNotEmpty()) ApiResult.Success(list) else ApiResult.Idle }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
dataManager.documents.value
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
private val _selectedDocumentId = MutableStateFlow<Int?>(null)
|
||||
// `initialSelectedDocumentId` seeds this in construction order so the
|
||||
// `stateIn` initial-value closure below observes the selected id *and*
|
||||
// the seeded `dataManager.documentDetail[id]` on first subscription —
|
||||
// used by the parity-gallery snapshot harness to render populated
|
||||
// detail screens on the very first composition frame.
|
||||
private val _selectedDocumentId = MutableStateFlow<Int?>(initialSelectedDocumentId)
|
||||
|
||||
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)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
_selectedDocumentId.value?.let { id ->
|
||||
dataManager.documentDetail.value[id]?.let { ApiResult.Success(it) }
|
||||
} ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
fun selectDocument(id: Int?) {
|
||||
_selectedDocumentId.value = id
|
||||
|
||||
+6
-1
@@ -87,7 +87,12 @@ class NotificationPreferencesViewModel(
|
||||
val preferencesState: StateFlow<ApiResult<NotificationPreference>> =
|
||||
dataManager.notificationPreferences
|
||||
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
dataManager.notificationPreferences.value
|
||||
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
private val _updateState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
|
||||
val updateState: StateFlow<ApiResult<NotificationPreference>> = _updateState.asStateFlow()
|
||||
|
||||
@@ -39,17 +39,33 @@ class ResidenceViewModel(
|
||||
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)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
dataManager.residences.value
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { ApiResult.Success(it) } ?: 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)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
dataManager.myResidences.value
|
||||
?.let { ApiResult.Success(it) } ?: 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)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
dataManager.totalSummary.value
|
||||
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
/** Drives the residence-scoped projections. */
|
||||
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
|
||||
@@ -58,13 +74,25 @@ class ResidenceViewModel(
|
||||
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)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
_selectedResidenceId.value?.let { id ->
|
||||
dataManager.tasksByResidence.value[id]?.let { ApiResult.Success(it) }
|
||||
} ?: 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)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
_selectedResidenceId.value?.let { id ->
|
||||
dataManager.contractorsByResidence.value[id]?.let { ApiResult.Success(it) }
|
||||
} ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
// ---------- Loading / error feedback ----------
|
||||
|
||||
|
||||
@@ -48,7 +48,12 @@ class TaskViewModel(
|
||||
val tasksState: StateFlow<ApiResult<TaskColumnsResponse>> =
|
||||
dataManager.allTasks
|
||||
.map { if (it != null) ApiResult.Success(it) else ApiResult.Idle }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, ApiResult.Idle)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
dataManager.allTasks.value
|
||||
?.let { ApiResult.Success(it) } ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
/** Drives the [tasksByResidenceState] projection key. */
|
||||
private val _selectedResidenceId = MutableStateFlow<Int?>(null)
|
||||
@@ -58,7 +63,13 @@ class TaskViewModel(
|
||||
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)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
_selectedResidenceId.value?.let { id ->
|
||||
dataManager.tasksByResidence.value[id]?.let { ApiResult.Success(it) }
|
||||
} ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
/** Drives the [taskCompletionsState] projection key. */
|
||||
private val _selectedTaskId = MutableStateFlow<Int?>(null)
|
||||
@@ -68,7 +79,13 @@ class TaskViewModel(
|
||||
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)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
_selectedTaskId.value?.let { id ->
|
||||
dataManager.taskCompletions.value[id]?.let { ApiResult.Success(it) }
|
||||
} ?: ApiResult.Idle,
|
||||
)
|
||||
|
||||
// ---------- Loading / error feedback ----------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user