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:
Trey T
2026-04-20 18:10:32 -05:00
parent 316b1f709d
commit 9fa58352c0
298 changed files with 2496 additions and 1343 deletions
@@ -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
@@ -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 ----------