Compare commits

..

24 Commits

Author SHA1 Message Date
Trey T
170a6d0e40 Parity gallery markdown: emit <img> tags with fixed width/height instead of markdown image syntax so every screenshot renders at identical size in Gitea's markdown view. Gitea strips inline styles but keeps width/height attributes.
Some checks are pending
Android UI Tests / ui-tests (pull_request) Waiting to run
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:34:34 -05:00
Trey T
16096f4b70 Parity gallery: force uniform aspect ratio + object-fit so Android and iOS screenshots render at identical display size regardless of native capture dimensions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:24:02 -05:00
Trey T
9fa58352c0 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>
2026-04-20 18:10:32 -05:00
Trey T
316b1f709d P3: NoIndependentViewModelStateFileScanTest — architecture regression gate
Fails CI if any future VM regresses to the pre-migration pattern of
owning independent MutableStateFlow read-state. Two assertions:

1. every_read_state_vm_accepts_iDataManager_ctor_param
   Scans composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/ and
   requires every VM to either declare `dataManager: IDataManager` as a
   constructor param or be in WORKFLOW_ONLY_VMS allowlist (currently
   TaskCompletion, Onboarding, PasswordReset).

2. read_state_flows_should_be_derived_not_independent
   Flags any `private val _xxxState = MutableStateFlow(...)` whose
   field-name prefix isn't on the mutation-feedback allowlist (create/
   update/delete/toggle/…). Read-state MUST derive from DataManager via
   .map + .stateIn pattern. AuthViewModel file-level allowlisted
   (every one of its 11 states is legitimate one-shot mutation feedback).

Paired stub in commonTest documents the rule cross-platform; real scan
lives in androidUnitTest where java.io.File works. Runs with
./gradlew :composeApp:testDebugUnitTest --tests "*architecture*".

See docs/parity-gallery.md "Known limitations" for the history of the
Dec 3 2025 partial migration this gate prevents regressing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:51:36 -05:00
Trey T
42ccbdcbd6 P2: iOS Full DI — all 11 VMs accept dataManager init param
Adds the DI seam to the 5 previously singleton-coupled VMs:
- VerifyEmailViewModel
- RegisterViewModel
- PasswordResetViewModel
- AppleSignInViewModel
- OnboardingTasksViewModel

All now accept init(dataManager: DataManagerObservable = .shared).

iOSApp.swift injects DataManagerObservable.shared at the root via
.environmentObject so descendant views can reach it via @EnvironmentObject
without implicit singleton reads.

Dependencies.swift factories updated to pass DataManager.shared explicitly
into Kotlin VM constructors — SKIE doesn't surface Kotlin default init
parameters as Swift defaults, so every Kotlin VM call-site needs the
explicit argument. Affects makeAuthViewModel, makeResidenceViewModel,
makeTaskViewModel, makeContractorViewModel, makeDocumentViewModel.

Full iOS build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:47:58 -05:00
Trey T
f0f8dfb68b 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>
2026-04-19 18:42:40 -05:00
Trey T
2230cde071 P0: IDataManager coverage gaps — contractorDetail/documentDetail/taskCompletions/contractorsByResidence
Adds 4 new StateFlow members to IDataManager + DataManager + InMemoryDataManager + FixtureDataManager:
- contractorDetail: Map<Int, Contractor> — cached detail fetches
- documentDetail: Map<Int, Document>
- taskCompletions: Map<Int, List<TaskCompletionResponse>>
- contractorsByResidence: Map<Int, List<ContractorSummary>>

APILayer now writes to these on successful detail/per-residence fetches:
- getTaskCompletions -> setTaskCompletions
- getDocument -> setDocumentDetail
- getContractor -> setContractorDetail
- getContractorsByResidence -> setContractorsForResidence

Fixture populated() seeds contractorDetail + contractorsByResidence.
Populated taskCompletions is empty (Fixtures doesn't define any completions yet).

Foundation for P1 — VMs can now derive every read-state from DataManager
reactively instead of owning independent MutableStateFlow fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:31:06 -05:00
Trey T
f83e89bee3 Parity gallery: honest populated-state coverage (10/34 surfaces differ)
Fixed & documented, not-just-marketed:
- HomeScreen now derives summary card from LocalDataManager.myResidences
  with VM fallback — populated PNG genuinely differs from empty.
- DocumentsScreen added same LocalDataManager fallback pattern + ambient
  subscription check (bypass SubscriptionHelper's singleton gate).
- ScreenshotTests.setUp seeds the global DataManager singleton from the
  fixture per variant (subscription/user/residences/tasks/docs/contractors/
  lookups). Unblocks screens that bypass LocalDataManager.

Honest coverage after all fixes: 10/34 surface-pairs genuinely differ
(home, profile, residences, contractors, all_tasks, task_templates_browser
in dark mode, etc.). The other 24 remain identical because their VMs
independently track state via APILayer.getXxx() calls that fail in
Robolectric — VM state stays Idle/Error, so gated "populated" branches
never render.

Root architectural fix needed (not landed here): every VM's xxxState
should mirror DataManager.xxx reactively instead of tracking API results
independently. That's a ~20-VM refactor tracked as follow-up in
docs/parity-gallery.md "Known limitations".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:31:52 -05:00
Trey T
ab0e5c450c Coverage: regenerate gallery — 40/40 Android surfaces rendering
Previous run left edit_document at 0/4 because the record task hadn't
recorded it; the other 39 surfaces' goldens were optimized in-place by
zopflipng (no visual change). Gallery HTML/markdown regenerated to
reflect 160 Android goldens (40 surfaces × 4 variants).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 02:28:59 -05:00
Trey T
b24469bf38 Coverage: Android gallery expansion 23 → 39 surfaces + regenerate gallery
Android Roborazzi re-recorded end-to-end. Coverage expanded from 23
surfaces × 4 variants (92 goldens) to 39 surfaces × 4 variants (156
goldens). Only edit_document still silent-fails — flagged for follow-up
PR requiring fixture DocumentResponse + a non-network Edit flow.

docs/parity-gallery.html + docs/parity-gallery-grid.md regenerated:
47 screens, 156 Android + 88 iOS = 244 PNGs. Compared to the prior
gallery commit (3944223) this doubles total coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:45:14 -05:00
Trey T
6c3c9d3e0c Coverage: iOS ViewModel DI seam + populated-state snapshots
6 user-facing ViewModels now accept optional `dataManager: DataManagerObservable = .shared`
init param — production call-sites unchanged; tests inject fixture-backed
observables. Refactored: ResidenceViewModel, TaskViewModel, ContractorViewModel,
DocumentViewModel, ProfileViewModel, LoginViewModel.

DataManagerObservable gains test-only init(observeSharedDataManager:) + convenience
init(kotlin: IDataManager).

SnapshotGalleryTests.setUp() resets .shared to FixtureDataManager.empty() per test;
populated tests call seedPopulated() to copy every StateFlow from
FixtureDataManager.populated() onto .shared synchronously. 15 populated surfaces ×
2 modes = 30 new PNGs.

iOS goldens: 58 → 88. 44 SnapshotGalleryTests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:45:04 -05:00
Trey T
3944223a5e P4: gitea-renderable parity-gallery-grid.md (markdown with inline images)
Gitea serves raw .html with Content-Type: text/plain for security, so the
HTML gallery only renders via `open` locally or external static hosting.
Add a parallel markdown version that gitea's /src/ view renders natively
with inline images.

View: https://gitea.treytartt.com/admin/honeyDueKMP/src/branch/rc/android-ios-parity/docs/parity-gallery-grid.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:59:06 -05:00
Trey T
031d61157f docs: regenerate parity gallery after tasks_empty_dark straggler 2026-04-18 23:53:53 -05:00
Trey T
f77c41f07a P2 addendum: tasks_empty_dark.png straggler 2026-04-18 23:53:53 -05:00
Trey T
fec0c4384a docs: regenerate parity gallery HTML (37 screens, 89 Android + 58 iOS) 2026-04-18 23:50:37 -05:00
Trey T
7a04ad4ff2 P2 addendum: 18 additional Android goldens (add/edit residence, join, manage users)
Late writes from the previous recordRoborazziDebug pass. Brings Android
coverage from 17 → 21 surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:50:33 -05:00
Trey T
707a90e5f1 P4: HTML parity gallery generator + comprehensive docs
scripts/build_parity_gallery.py walks both golden directories and pairs
Android↔iOS PNGs by filename convention into docs/parity-gallery.html —
a self-contained HTML file with relative <img> paths that renders
directly from gitea's raw-file view (no server needed).

Current output: 34 screens × 71 Android + 58 iOS images, grouped per
screen with sticky headers and per-screen anchor nav.

docs/parity-gallery.md: full workflow guide — verify vs record, adding
screens to both platforms, approving intentional drift, tool install,
size budget, known limitations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:45:20 -05:00
Trey T
6cc5295db8 P2: Android parity gallery — real-screen captures (partial, 17/40 surfaces)
Replaces the synthetic theme-showcase ScreenshotTests with real screens
rendered against FixtureDataManager.empty() / .populated() via
LocalDataManager. GallerySurfaces.kt manifest declares 40 screens.

Landed: 68 goldens covering 17 surfaces (login, register, password-reset
chain, 10 onboarding screens, home, residences-list).

Missing: 23 detail/edit screens that need a specific fixture model passed
via GallerySurfaces.kt — tracked as follow-up in docs/parity-gallery.md.
Non-blocking: these render silently as blank and don't fail the suite.

Android total: 2.5 MB, avg 41 KB, max 113 KB — well under the 150 KB
per-file budget enforced by the CI size gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:45:12 -05:00
Trey T
3bac38449c P3.1: iOS goldens @2x + PNG optimizer + Makefile record/verify targets
- SnapshotGalleryTests rendered at displayScale: 2.0 (was native 3.0)
  → 49MB → 15MB (~69% reduction)
- Records via SNAPSHOT_TESTING_RECORD=1 env var (no code edits needed)
- scripts/optimize_goldens.sh runs zopflipng (or pngcrush fallback)
  over both iOS and Android golden dirs
- scripts/{record,verify}_snapshots.sh one-command wrappers
- Makefile targets: make {record,verify,optimize}-snapshots

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:45:02 -05:00
Trey T
6f2fb629c9 P3: iOS parity gallery (swift-snapshot-testing, 1.17.0+)
Records 58 baseline PNGs across 29 primary SwiftUI screens × {light, dark}
for the honeyDue iOS app. Covers auth, password reset, onboarding,
residences, tasks, contractors, documents, profile, and subscription
surfaces — everything that's instantiable without complex runtime context.

State coverage is empty-only for this first pass: views currently spin up
their own ViewModels which read DataManagerObservable.shared directly, and
the test host has no login → all flows render their empty states. A
follow-up PR adds an optional `dataManager:` init param to each
*ViewModel.swift so populated-state snapshots (backed by P1's
FixtureDataManager) can land.

Tolerance knobs: pixelPrecision 0.97 / perceptualPrecision 0.95 — tuned to
absorb animation-frame drift (gradient blobs, focus rings) while catching
structural regressions.

Tooling: swift-snapshot-testing SPM dep added to the HoneyDueTests target
only (not the app target) via scripts/add_snapshot_testing.rb, which is an
idempotent xcodeproj-gem script so the edit is reproducible rather than a
hand-crafted pbxproj diff. Pins resolve to 1.19.2 (up-to-next-major from
the 1.17.0 plan floor).

Blocks regressions at PR time via `xcodebuild test
-only-testing:HoneyDueTests/SnapshotGalleryTests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:37:09 -05:00
Trey T
47eaf5a0c0 P1: Shared FixtureDataManager (empty + populated) for cross-platform snapshots
InMemoryDataManager + Fixtures with deterministic data (fixed clock 2026-04-15,
2 residences, 8 tasks, 3 contractors, 5 documents). FixtureDataManager.empty()
and .populated() factories. Exposed to Swift via SKIE.

Expanded IDataManager surface (5 -> 22 members) so fixtures cover every
StateFlow and lookup helper screens read: myResidences, allTasks,
tasksByResidence, documents, documentsByResidence, contractors, residenceTypes,
taskFrequencies, taskPriorities, taskCategories, contractorSpecialties,
taskTemplates, taskTemplatesGrouped, residenceSummaries, upgradeTriggers,
promotions, plus get{ResidenceType,TaskFrequency,TaskPriority,TaskCategory,
ContractorSpecialty}(id) lookup helpers. DataManager implementation is a pure
override-keyword addition — no behavior change.

Enables P2 (Android gallery) + P3 (iOS gallery) to render real screens against
identical inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:22:41 -05:00
Trey T
c57743dca0 Fix: expect/actual enableTestTagsAsResourceId() for iOS compile
testTagsAsResourceId is Android-only; its use in commonMain broke
compileKotlinIosSimulatorArm64. Wrap behind expect fun — Android impl
sets the semantic, other platforms return Modifier unchanged. Blocks
P3 iOS parity gallery otherwise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:20:48 -05:00
Trey T
f56d854acc P0.3: add iOS @Environment(\.dataManager) key
Introduces DataManagerEnvironmentKey + EnvironmentValues.dataManager so
SwiftUI views can resolve DataManagerObservable via @Environment, mirroring
Compose's LocalDataManager ambient on the Kotlin side.

No view migrations yet — views continue to read DataManagerObservable.shared
directly. The actual screen-level substitution (fake DataManager for
parity-gallery / tests / previews) lands in P1 when ViewModels gain an
optional init param that accepts the environment-resolved observable. For
this commit we only need the key so P1 can wire against it.

Note: the iosSimulator Kotlin compile is broken at baseline (bb4cbd5)
with pre-existing "Unresolved reference 'testTagsAsResourceId'" errors
across 20+ screen files — Android-only semantics API imported in
commonMain. Swift-parse of the new file succeeds. Verified by checking
out bb4cbd5 and rerunning ./gradlew :composeApp:compileKotlinIosSimulatorArm64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:11:15 -05:00
Trey T
00e215920a P0.2: migrate screens to LocalDataManager.current
Swaps direct `DataManager.xxx` access for `LocalDataManager.current.xxx`
across every Compose screen under ui/screens/** that references the
singleton. Each composable resolves the ambient once at the top of its
body and reuses the local val for subsequent reads — keeping rewrites
minimal and predictable.

Screens touched:
  - HomeScreen                          (totalSummary)
  - ResidencesScreen                    (totalSummary)
  - ResidenceDetailScreen               (currentUser)
  - ResidenceFormScreen                 (currentUser)
  - ProfileScreen                       (currentUser + subscription)
  - ContractorDetailScreen              (residences)
  - subscription/FeatureComparisonScreen (featureBenefits)
  - onboarding/OnboardingFirstTaskContent (residences × 3 sites)

No behavior change — in production the ambient default resolves to the
same DataManager singleton. The change is purely so tests, previews, and
the parity-gallery can `CompositionLocalProvider(LocalDataManager provides fake)`
to substitute a fake without tearing screens apart.

Files under ui/subscription/** and ui/components/AddTaskDialog.kt also
reference DataManager but live outside ui/screens/** (plan's scope) —
flagged for a follow-up pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:08:58 -05:00
410 changed files with 6622 additions and 994 deletions

55
Makefile Normal file
View File

@@ -0,0 +1,55 @@
# Makefile for HoneyDue KMM — wraps the long-form commands you actually
# use every day. If you find yourself copy/pasting a command twice, it
# belongs here.
#
# Quick reference
# ---------------
# make verify-snapshots # fast; run on every PR, CI runs this
# make record-snapshots # slow; regenerate baselines after UI change
# make optimize-goldens # rarely needed — record-snapshots runs this
#
.PHONY: help record-snapshots verify-snapshots optimize-goldens \
record-ios record-android verify-ios verify-android
help:
@echo "HoneyDue KMM — common tasks"
@echo ""
@echo " make verify-snapshots Verify iOS + Android parity goldens (CI gate)"
@echo " make record-snapshots Regenerate iOS + Android goldens + optimize"
@echo " make optimize-goldens Run the PNG optimizer across both directories"
@echo ""
@echo " make verify-ios Verify just the iOS gallery"
@echo " make verify-android Verify just the Android gallery"
@echo " make record-ios Regenerate just the iOS gallery"
@echo " make record-android Regenerate just the Android gallery"
@echo ""
# ---- Parity gallery (combined) ----
# Regenerate every parity-gallery golden (iOS + Android) and shrink the
# output PNGs. Slow (~3 min); run after intentional UI changes only.
record-snapshots:
@./scripts/record_snapshots.sh
# Verify current UI matches committed goldens. Fast (~1 min). PR gate.
verify-snapshots:
@./scripts/verify_snapshots.sh
# Optimize every PNG golden in-place (idempotent). Usually invoked
# automatically by record-snapshots; exposed here for one-off cleanup.
optimize-goldens:
@./scripts/optimize_goldens.sh
# ---- Parity gallery (single platform) ----
record-ios:
@./scripts/record_snapshots.sh --ios-only
record-android:
@./scripts/record_snapshots.sh --android-only
verify-ios:
@./scripts/verify_snapshots.sh --ios-only
verify-android:
@./scripts/verify_snapshots.sh --android-only

View File

@@ -223,11 +223,21 @@ compose.desktop {
}
}
// Roborazzi screenshot-regression plugin (P8). Pin the golden-image
// output directory inside the test source set so goldens live in git
// alongside the tests themselves. Anything under build/ is gitignored
// and gets blown away by `gradle clean` — not where committed goldens
// belong.
// Roborazzi screenshot-regression plugin (parity gallery, P2). Pin the
// golden-image output directory inside the test source set so goldens live
// in git alongside the tests themselves. Anything under build/ is
// gitignored and gets blown away by `gradle clean` — not where committed
// goldens belong.
//
// NOTE on path mismatch: `captureRoboImage(filePath = ...)` in
// ScreenshotTests.kt takes a *relative path* resolved against the Gradle
// test task's working directory (`composeApp/`). We intentionally point
// that same path at `src/androidUnitTest/roborazzi/...` — and configure
// the plugin extension below to match — so record and verify read from
// and write to the exact same committed-golden location. Any other
// arrangement results in the "original file was not found" error because
// the plugin doesn't currently auto-copy between `build/outputs/roborazzi`
// and the extension outputDir for the KMM Android target.
roborazzi {
outputDir.set(layout.projectDirectory.dir("src/androidUnitTest/roborazzi"))
}

View File

@@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.content.FileProvider
import java.io.File
@@ -58,6 +59,15 @@ actual fun rememberImagePicker(
actual fun rememberCameraPicker(
onImageCaptured: (ImageData) -> Unit
): () -> Unit {
// Compose previews and Roborazzi snapshot tests run without a
// `FileProvider` resolvable cache path — `getUriForFile` throws
// `Failed to find configured root...` because the test cache dir
// isn't registered in the manifest's `file_paths.xml`. The launch
// callback is never invoked in a preview/snapshot anyway, so
// returning a no-op keeps the composition clean.
if (LocalInspectionMode.current) {
return {}
}
val context = LocalContext.current
// Create a temp file URI for the camera to save to

View File

@@ -0,0 +1,9 @@
package com.tt.honeyDue.ui.support
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
actual fun Modifier.enableTestTagsAsResourceId(): Modifier =
this.semantics { testTagsAsResourceId = true }

View File

@@ -0,0 +1,130 @@
package com.tt.honeyDue.architecture
import java.io.File
import kotlin.test.Test
import kotlin.test.fail
/**
* Architecture regression gate.
*
* Scans `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/` and
* asserts every ViewModel either:
* a) accepts `dataManager: IDataManager` as a constructor parameter, or
* b) is explicitly allowlisted in [WORKFLOW_ONLY_VMS] as a
* workflow/mutation-only VM.
*
* Prevents the Dec 3 2025 regression (4 VMs holding independent
* `MutableStateFlow` read-state instead of deriving from DataManager).
* See `docs/parity-gallery.md` "Known limitations" for the history.
*
* Workflow / write-only (no read-state to mirror):
* * `TaskCompletionViewModel` — single-shot create mutation
* * `OnboardingViewModel` — wizard form + per-step ApiResult
* * `PasswordResetViewModel` — wizard form + per-step ApiResult
*
* Everyone else must accept the `dataManager` ctor param.
*/
class NoIndependentViewModelStateFileScanTest {
@Test
fun every_read_state_vm_accepts_iDataManager_ctor_param() {
val vmSources = findViewModelSources()
val violations = mutableListOf<String>()
vmSources.forEach { file ->
val name = file.name
if (name in WORKFLOW_ONLY_VMS) return@forEach
val body = file.readText()
val hasCtorParam = body.contains(Regex("""dataManager:\s*IDataManager"""))
if (!hasCtorParam) {
violations.add(
"$name — expected `dataManager: IDataManager = DataManager` " +
"constructor parameter. Without this, read-state can't derive " +
"from the DataManager single source of truth and snapshot " +
"tests can't substitute a fixture. " +
"If this VM genuinely has no read-state (workflow / mutation only), " +
"add its filename to WORKFLOW_ONLY_VMS with justification.",
)
}
}
if (violations.isNotEmpty()) {
fail(
"ViewModel architecture regression (see docs/parity-gallery.md):\n" +
violations.joinToString(separator = "\n") { " - $it" },
)
}
}
@Test
fun read_state_flows_should_be_derived_not_independent() {
val vmSources = findViewModelSources()
val violations = mutableListOf<String>()
// Names of fields that track one-shot mutation/workflow feedback —
// exempt from the "must be derived" rule.
val mutationFieldPrefixes = listOf(
"create", "update", "delete", "toggle", "download",
"upload", "archive", "unarchive", "cancel", "uncancel",
"mark", "generate", "request", "submit", "login", "register",
"reset", "forgot", "verify", "apple", "google",
"join", "addNew", "addTask", "taskAddNew",
"action", "currentStep", "resetToken", "email", "selected",
// Local-only state not cached by DataManager:
"category", // NotificationPreferencesViewModel per-channel local toggles
)
vmSources.forEach { file ->
val name = file.name
if (name in WORKFLOW_ONLY_VMS) return@forEach
if (name == "AuthViewModel.kt") return@forEach // 11 one-shot states, all mutation-feedback; allowlisted as a file
val body = file.readText()
val mutableReads = Regex("""private val (_[a-zA-Z]+State)\s*=\s*MutableStateFlow""")
.findAll(body).map { it.groupValues[1] }.toList()
mutableReads.forEach { fieldName ->
val root = fieldName.removePrefix("_").removeSuffix("State")
val isMutationFeedback = mutationFieldPrefixes.any {
root.lowercase().startsWith(it.lowercase())
}
if (!isMutationFeedback) {
violations.add(
"$name — field `$fieldName` looks like cached read-state " +
"(not matching any mutation-feedback prefix). Derive it from " +
"DataManager via `dataManager.xxx.map { ... }.stateIn(...)` " +
"instead of owning a MutableStateFlow. If this field really " +
"is mutation feedback, add its name prefix to " +
"mutationFieldPrefixes in this test.",
)
}
}
}
if (violations.isNotEmpty()) {
fail(
"ViewModel state-ownership regression (see docs/parity-gallery.md):\n" +
violations.joinToString(separator = "\n") { " - $it" },
)
}
}
private fun findViewModelSources(): List<File> {
// Test cwd is `composeApp/` — resolve from the project dir.
val vmDir = File("src/commonMain/kotlin/com/tt/honeyDue/viewmodel")
check(vmDir.exists()) {
"expected VM source directory not found: ${vmDir.absolutePath} (cwd=${File(".").absolutePath})"
}
return vmDir.listFiles { f -> f.extension == "kt" }?.toList().orEmpty()
}
companion object {
/** VMs that legitimately don't need DataManager injection. */
val WORKFLOW_ONLY_VMS: Set<String> = setOf(
"TaskCompletionViewModel.kt",
"OnboardingViewModel.kt",
"PasswordResetViewModel.kt",
)
}
}

View File

@@ -0,0 +1,68 @@
package com.tt.honeyDue.screenshot
import com.tt.honeyDue.testing.GalleryScreens
import com.tt.honeyDue.testing.Platform
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Parity gate — asserts [gallerySurfaces] is exactly the set of screens
* declared in [GalleryScreens] with [Platform.ANDROID] in their platforms.
*
* If this fails, either:
* - A new screen was added to [gallerySurfaces] but missing from the
* canonical manifest — update [GalleryScreens.all].
* - A new screen was added to the manifest but not wired into
* [gallerySurfaces] — add the corresponding `GallerySurface(...)`
* entry.
* - A rename landed on only one side — reconcile.
*
* This keeps Android and iOS from silently drifting apart in coverage.
* The iOS equivalent (`GalleryManifestParityTest.swift`) enforces the
* same invariant on the Swift test file.
*/
class GalleryManifestParityTest {
@Test
fun android_surfaces_match_manifest_exactly() {
val actual = gallerySurfaces.map { it.name }.toSet()
val expected = GalleryScreens.forAndroid.keys
val missing = expected - actual
val extra = actual - expected
if (missing.isNotEmpty() || extra.isNotEmpty()) {
val message = buildString {
appendLine("Android GallerySurfaces drifted from canonical manifest.")
if (missing.isNotEmpty()) {
appendLine()
appendLine("Screens in manifest but missing from GallerySurfaces.kt:")
missing.sorted().forEach { appendLine(" - $it") }
}
if (extra.isNotEmpty()) {
appendLine()
appendLine("Screens in GallerySurfaces.kt but missing from manifest:")
extra.sorted().forEach { appendLine(" - $it") }
}
appendLine()
appendLine("Reconcile by editing com.tt.honeyDue.testing.GalleryScreens and/or")
appendLine("com.tt.honeyDue.screenshot.GallerySurfaces so both agree.")
}
kotlin.test.fail(message)
}
assertEquals(expected, actual)
}
@Test
fun no_duplicate_surface_names() {
val duplicates = gallerySurfaces.map { it.name }
.groupingBy { it }
.eachCount()
.filterValues { it > 1 }
assertTrue(
duplicates.isEmpty(),
"Duplicate surface names in GallerySurfaces.kt: $duplicates",
)
}
}

View File

@@ -0,0 +1,383 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.tt.honeyDue.screenshot
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.tt.honeyDue.testing.Fixtures
import com.tt.honeyDue.ui.screens.AddDocumentScreen
import com.tt.honeyDue.ui.screens.AddResidenceScreen
import com.tt.honeyDue.ui.screens.AllTasksScreen
import com.tt.honeyDue.ui.screens.BiometricLockScreen
import com.tt.honeyDue.ui.screens.CompleteTaskScreen
import com.tt.honeyDue.ui.screens.ContractorDetailScreen
import com.tt.honeyDue.ui.screens.ContractorsScreen
import com.tt.honeyDue.ui.screens.DocumentDetailScreen
import com.tt.honeyDue.ui.screens.DocumentsScreen
import com.tt.honeyDue.ui.screens.EditDocumentScreen
import com.tt.honeyDue.ui.screens.EditResidenceScreen
import com.tt.honeyDue.ui.screens.EditTaskScreen
import com.tt.honeyDue.ui.screens.ForgotPasswordScreen
import com.tt.honeyDue.ui.screens.HomeScreen
import com.tt.honeyDue.ui.screens.LoginScreen
import com.tt.honeyDue.ui.screens.ManageUsersScreen
import com.tt.honeyDue.ui.screens.NotificationPreferencesScreen
import com.tt.honeyDue.ui.screens.ProfileScreen
import com.tt.honeyDue.ui.screens.RegisterScreen
import com.tt.honeyDue.ui.screens.ResetPasswordScreen
import com.tt.honeyDue.ui.screens.ResidenceDetailScreen
import com.tt.honeyDue.ui.screens.ResidencesScreen
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
import com.tt.honeyDue.ui.screens.onboarding.OnboardingCreateAccountContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingFirstTaskContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingHomeProfileContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingJoinResidenceContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingLocationContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingNameResidenceContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingSubscriptionContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingValuePropsContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingVerifyEmailContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingWelcomeContent
import com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen
import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen
import com.tt.honeyDue.ui.screens.task.AddTaskWithResidenceScreen
import com.tt.honeyDue.ui.screens.task.TaskSuggestionsScreen
import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
import com.tt.honeyDue.viewmodel.ContractorViewModel
import com.tt.honeyDue.viewmodel.DocumentViewModel
import com.tt.honeyDue.viewmodel.OnboardingViewModel
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
/**
* Declarative manifest of every Android gallery surface. Must stay in sync
* with the canonical [com.tt.honeyDue.testing.GalleryScreens] manifest —
* [GalleryManifestParityTest] fails CI if the two drift.
*
* Scope: the screens users land on. We deliberately skip:
* - dialogs that live inside a host screen (already captured on the host),
* - animation sub-views / decorative components in AnimationTesting/,
* - widget views (Android Glance / iOS WidgetKit — separate surface),
* - shared helper composables (loaders, error rows, thumbnails — they
* only appear as part of a parent screen).
*
* Detail-VM pattern (contractor_detail, document_detail, edit_document):
* the VM is created with the fixture id already pre-selected, so
* `stateIn(SharingStarted.Eagerly, initialValue = dataManager.x[id])`
* emits `Success(entity)` on first composition. Without this pre-select,
* the screens' own `LaunchedEffect(id) { vm.loadX(id) }` dispatches the id
* assignment to a coroutine that runs *after* Roborazzi captures the
* frame — so both empty and populated captures would render the `Idle`
* state and be byte-identical.
*
* Screens that require a construction-time ViewModel
* ([OnboardingViewModel], [PasswordResetViewModel]) instantiate it inline
* here. The production code paths start the viewmodel's own
* `launch { APILayer.xxx() }` on first composition — those calls fail fast
* in the hermetic Robolectric environment, but the composition itself
* renders the surface from the injected
* [com.tt.honeyDue.data.LocalDataManager] before any network result
* arrives, which is exactly what we want to compare against iOS.
*/
data class GallerySurface(
/** Snake-case identifier; used as the golden file-name prefix. */
val name: String,
val content: @Composable () -> Unit,
) {
/**
* ParameterizedRobolectricTestRunner uses `toString()` in the test
* display name when the `{0}` pattern is set. The default data-class
* toString includes the composable lambda hash — not useful. Override
* so test reports show `ScreenshotTests[login]` instead of
* `ScreenshotTests[GallerySurface(name=login, content=...@abc123)]`.
*/
override fun toString(): String = name
}
val gallerySurfaces: List<GallerySurface> = listOf(
// ---------- Auth ----------
GallerySurface("login") {
LoginScreen(
onLoginSuccess = {},
onNavigateToRegister = {},
onNavigateToForgotPassword = {},
)
},
GallerySurface("register") {
RegisterScreen(
onRegisterSuccess = {},
onNavigateBack = {},
)
},
GallerySurface("forgot_password") {
ForgotPasswordScreen(
onNavigateBack = {},
onNavigateToVerify = {},
onNavigateToReset = {},
viewModel = PasswordResetViewModel(),
)
},
GallerySurface("verify_reset_code") {
VerifyResetCodeScreen(
onNavigateBack = {},
onNavigateToReset = {},
viewModel = PasswordResetViewModel(),
)
},
GallerySurface("reset_password") {
ResetPasswordScreen(
onPasswordResetSuccess = {},
onNavigateBack = {},
viewModel = PasswordResetViewModel(),
)
},
GallerySurface("verify_email") {
VerifyEmailScreen(
onVerifySuccess = {},
onLogout = {},
)
},
// ---------- Onboarding ----------
GallerySurface("onboarding_welcome") {
OnboardingWelcomeContent(
onStartFresh = {},
onJoinExisting = {},
onLogin = {},
)
},
GallerySurface("onboarding_value_props") {
OnboardingValuePropsContent(onContinue = {})
},
GallerySurface("onboarding_create_account") {
OnboardingCreateAccountContent(
viewModel = OnboardingViewModel(),
onAccountCreated = {},
)
},
GallerySurface("onboarding_verify_email") {
OnboardingVerifyEmailContent(
viewModel = OnboardingViewModel(),
onVerified = {},
)
},
GallerySurface("onboarding_location") {
OnboardingLocationContent(
viewModel = OnboardingViewModel(),
onLocationDetected = {},
onSkip = {},
)
},
GallerySurface("onboarding_name_residence") {
OnboardingNameResidenceContent(
viewModel = OnboardingViewModel(),
onContinue = {},
)
},
GallerySurface("onboarding_home_profile") {
OnboardingHomeProfileContent(
viewModel = OnboardingViewModel(),
onContinue = {},
onSkip = {},
)
},
GallerySurface("onboarding_join_residence") {
OnboardingJoinResidenceContent(
viewModel = OnboardingViewModel(),
onJoined = {},
)
},
GallerySurface("onboarding_first_task") {
OnboardingFirstTaskContent(
viewModel = OnboardingViewModel(),
onTasksAdded = {},
)
},
GallerySurface("onboarding_subscription") {
OnboardingSubscriptionContent(
onSubscribe = {},
onSkip = {},
)
},
// ---------- Home (Android-only dashboard) ----------
GallerySurface("home") {
HomeScreen(
onNavigateToResidences = {},
onNavigateToTasks = {},
onLogout = {},
)
},
// ---------- Residences ----------
GallerySurface("residences") {
ResidencesScreen(
onResidenceClick = {},
onAddResidence = {},
onJoinResidence = {},
onLogout = {},
)
},
GallerySurface("residence_detail") {
ResidenceDetailScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onNavigateToEditResidence = {},
onNavigateToEditTask = {},
)
},
GallerySurface("add_residence") {
AddResidenceScreen(
onNavigateBack = {},
onResidenceCreated = {},
)
},
GallerySurface("edit_residence") {
EditResidenceScreen(
residence = Fixtures.primaryHome,
onNavigateBack = {},
onResidenceUpdated = {},
)
},
GallerySurface("join_residence") {
JoinResidenceScreen(
onNavigateBack = {},
onJoined = {},
)
},
GallerySurface("manage_users") {
ManageUsersScreen(
residenceId = Fixtures.primaryHome.id,
residenceName = Fixtures.primaryHome.name,
isPrimaryOwner = true,
residenceOwnerId = Fixtures.primaryHome.ownerId,
onNavigateBack = {},
)
},
// ---------- Tasks ----------
GallerySurface("all_tasks") {
AllTasksScreen(onNavigateToEditTask = {})
},
GallerySurface("add_task_with_residence") {
AddTaskWithResidenceScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onCreated = {},
)
},
GallerySurface("edit_task") {
EditTaskScreen(
task = Fixtures.tasks.first(),
onNavigateBack = {},
onTaskUpdated = {},
)
},
GallerySurface("complete_task") {
val task = Fixtures.tasks.first()
CompleteTaskScreen(
taskId = task.id,
taskTitle = task.title,
residenceName = Fixtures.primaryHome.name,
onNavigateBack = {},
onComplete = { _, _ -> },
)
},
GallerySurface("task_suggestions") {
TaskSuggestionsScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onSuggestionAccepted = {},
)
},
GallerySurface("task_templates_browser") {
TaskTemplatesBrowserScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
)
},
// ---------- Contractors ----------
GallerySurface("contractors") {
ContractorsScreen(
onNavigateBack = {},
onNavigateToContractorDetail = {},
)
},
GallerySurface("contractor_detail") {
val id = Fixtures.contractors.first().id
// Pass `initialSelectedContractorId` at VM construction so the
// synchronous `stateIn` initial-value closure observes both the
// id AND the fixture-seeded `dataManager.contractorDetail[id]`,
// emitting `Success(contractor)` on first composition. Without
// this the screen's own `LaunchedEffect(id) { vm.loadContractorDetail(id) }`
// dispatches the id assignment to a coroutine that runs after
// the frame is captured, leaving both empty and populated
// captures byte-identical on the `Idle` branch.
val vm = remember { ContractorViewModel(initialSelectedContractorId = id) }
ContractorDetailScreen(
contractorId = id,
onNavigateBack = {},
viewModel = vm,
)
},
// ---------- Documents ----------
GallerySurface("documents") {
DocumentsScreen(
onNavigateBack = {},
residenceId = Fixtures.primaryHome.id,
)
},
GallerySurface("document_detail") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
DocumentDetailScreen(
documentId = id,
onNavigateBack = {},
onNavigateToEdit = { _ -> },
documentViewModel = vm,
)
},
GallerySurface("add_document") {
AddDocumentScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onDocumentCreated = {},
)
},
GallerySurface("edit_document") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
EditDocumentScreen(
documentId = id,
onNavigateBack = {},
documentViewModel = vm,
)
},
// ---------- Profile / settings ----------
GallerySurface("profile") {
ProfileScreen(
onNavigateBack = {},
onLogout = {},
)
},
GallerySurface("notification_preferences") {
NotificationPreferencesScreen(onNavigateBack = {})
},
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("biometric_lock") {
BiometricLockScreen(onUnlocked = {})
},
// ---------- Subscription ----------
GallerySurface("feature_comparison") {
FeatureComparisonScreen(
onNavigateBack = {},
onNavigateToUpgrade = {},
)
},
)

View File

@@ -1,485 +1,236 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.tt.honeyDue.screenshot
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Task
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.material3.ExperimentalMaterial3Api
import com.github.takahirom.roborazzi.RoborazziOptions
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.data.LocalDataManager
import com.tt.honeyDue.testing.FixtureDataManager
import com.tt.honeyDue.testing.GalleryCategory
import com.tt.honeyDue.testing.GalleryScreens
import com.tt.honeyDue.ui.theme.AppThemes
import com.tt.honeyDue.ui.theme.HoneyDueTheme
import com.tt.honeyDue.ui.theme.ThemeColors
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Roborazzi-driven screenshot regression tests (P8).
* Parity-gallery Roborazzi snapshot tests.
*
* Runs entirely on the Robolectric unit-test classpath — no emulator
* required. The goal is to catch accidental UI drift (colour, spacing,
* typography) on PRs by diffing generated PNGs against a committed
* golden set.
* Variant matrix (driven by [GalleryCategory] in the canonical
* [GalleryScreens] manifest):
*
* Matrix: 6 surfaces × 3 themes (Default / Ocean / Midnight) × 2 modes
* (light / dark) = 36 images. This is a conservative baseline; the full
* 11-theme matrix would produce 132+ images and is deferred.
* DataCarrying surfaces — capture 4 variants:
* surface_empty_light.png (empty fixture, no lookups, light)
* surface_empty_dark.png (empty fixture, no lookups, dark)
* surface_populated_light.png (populated fixture, light)
* surface_populated_dark.png (populated fixture, dark)
*
* Implementation notes:
* - We use the top-level `captureRoboImage(path) { composable }` form
* from roborazzi-compose. That helper registers
* `RoborazziTransparentActivity` at runtime via Robolectric's shadow
* PackageManager, so we don't need `createComposeRule()` /
* `ActivityScenarioRule<ComponentActivity>` and therefore avoid the
* "Unable to resolve activity for Intent MAIN/LAUNCHER cmp=.../ComponentActivity"
* failure that bit the initial scaffolding (RoboMonitoringInstrumentation:102).
* - Goldens land under `composeApp/build/outputs/roborazzi/`, which the
* Roborazzi Gradle plugin picks up for record / verify / compare.
* DataFree surfaces — capture 2 variants:
* surface_light.png (empty fixture, lookups seeded, light)
* surface_dark.png (empty fixture, lookups seeded, dark)
*
* Workflow:
* - Initial record: `./gradlew :composeApp:recordRoborazziDebug`
* - Verify in CI: `./gradlew :composeApp:verifyRoborazziDebug`
* - View diffs: `./gradlew :composeApp:compareRoborazziDebug`
* The `empty` fixture for DataCarrying variants passes
* `seedLookups = false` so form dropdowns render their empty state
* (yielding a visible populated-vs-empty diff for forms that read
* lookups from `DataManager`). The `empty` fixture for DataFree
* variants passes `seedLookups = true` because those screens expect
* realistic production lookups even when the user has no entities yet.
*
* DataFree surfaces omit the populated variant entirely — the screens
* render no entity data, so `populated` would be byte-identical to
* `empty` and add zero signal.
*
* Granular CI failures: one parameterized test per surface means the
* report shows `ScreenshotTests[login]`, `ScreenshotTests[tasks]`, etc.
* rather than one monolithic failure when any surface drifts.
*
* Why the goldens land directly under `src/androidUnitTest/roborazzi/`:
* Roborazzi resolves `captureRoboImage(filePath = …)` relative to the
* Gradle test task's working directory (the module root). Writing to
* the same directory where goldens are committed means record and
* verify round-trip through one canonical location.
*/
@RunWith(RobolectricTestRunner::class)
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h800dp-mdpi")
class ScreenshotTests {
class ScreenshotTests(
private val surface: GallerySurface,
) {
// ---------- Login screen showcase ----------
@Test
fun loginScreen_default_light() = runScreen("login_default_light", AppThemes.Default, darkTheme = false) {
LoginShowcase()
/**
* Compose Multiplatform's `stringResource()` loads text via a
* JVM-static context held by `AndroidContextProvider`. Under
* Robolectric unit tests the `ContentProvider` that normally
* populates it never runs, so every `stringResource(...)` call throws
* "Android context is not initialized."
*
* Install the context eagerly via reflection before each test.
* `AndroidContextProvider` is `internal`, but its static slot is
* writable through the generated `Companion.setANDROID_CONTEXT`
* accessor.
*/
@Before
fun bootstrapComposeResources() {
val appContext = ApplicationProvider.getApplicationContext<android.content.Context>()
val providerClass = Class.forName("org.jetbrains.compose.resources.AndroidContextProvider")
val companionField = providerClass.getDeclaredField("Companion").apply { isAccessible = true }
val companion = companionField.get(null)
val setter = companion.javaClass.getMethod("setANDROID_CONTEXT", android.content.Context::class.java)
setter.invoke(companion, appContext)
}
@Test
fun loginScreen_default_dark() = runScreen("login_default_dark", AppThemes.Default, darkTheme = true) {
LoginShowcase()
}
fun captureAllVariants() {
val screen = GalleryScreens.forAndroid[surface.name]
?: error(
"Surface '${surface.name}' is in GallerySurfaces.kt but not in " +
"GalleryScreens.all (canonical manifest). " +
"GalleryManifestParityTest should have caught this.",
)
@Test
fun loginScreen_ocean_light() = runScreen("login_ocean_light", AppThemes.Ocean, darkTheme = false) {
LoginShowcase()
}
val variants = when (screen.category) {
GalleryCategory.DataCarrying -> Variant.dataCarrying
GalleryCategory.DataFree -> Variant.dataFree
}
@Test
fun loginScreen_ocean_dark() = runScreen("login_ocean_dark", AppThemes.Ocean, darkTheme = true) {
LoginShowcase()
}
@Test
fun loginScreen_midnight_light() = runScreen("login_midnight_light", AppThemes.Midnight, darkTheme = false) {
LoginShowcase()
}
@Test
fun loginScreen_midnight_dark() = runScreen("login_midnight_dark", AppThemes.Midnight, darkTheme = true) {
LoginShowcase()
}
// ---------- Tasks list showcase ----------
@Test
fun tasksScreen_default_light() = runScreen("tasks_default_light", AppThemes.Default, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_default_dark() = runScreen("tasks_default_dark", AppThemes.Default, darkTheme = true) {
TasksShowcase()
}
@Test
fun tasksScreen_ocean_light() = runScreen("tasks_ocean_light", AppThemes.Ocean, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_ocean_dark() = runScreen("tasks_ocean_dark", AppThemes.Ocean, darkTheme = true) {
TasksShowcase()
}
@Test
fun tasksScreen_midnight_light() = runScreen("tasks_midnight_light", AppThemes.Midnight, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_midnight_dark() = runScreen("tasks_midnight_dark", AppThemes.Midnight, darkTheme = true) {
TasksShowcase()
}
// ---------- Residences list showcase ----------
@Test
fun residencesScreen_default_light() = runScreen("residences_default_light", AppThemes.Default, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_default_dark() = runScreen("residences_default_dark", AppThemes.Default, darkTheme = true) {
ResidencesShowcase()
}
@Test
fun residencesScreen_ocean_light() = runScreen("residences_ocean_light", AppThemes.Ocean, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_ocean_dark() = runScreen("residences_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ResidencesShowcase()
}
@Test
fun residencesScreen_midnight_light() = runScreen("residences_midnight_light", AppThemes.Midnight, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_midnight_dark() = runScreen("residences_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ResidencesShowcase()
}
// ---------- Profile/theme-selection / complete-task showcases ----------
@Test
fun profileScreen_default_light() = runScreen("profile_default_light", AppThemes.Default, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_default_dark() = runScreen("profile_default_dark", AppThemes.Default, darkTheme = true) {
ProfileShowcase()
}
@Test
fun profileScreen_ocean_light() = runScreen("profile_ocean_light", AppThemes.Ocean, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_ocean_dark() = runScreen("profile_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ProfileShowcase()
}
@Test
fun profileScreen_midnight_light() = runScreen("profile_midnight_light", AppThemes.Midnight, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_midnight_dark() = runScreen("profile_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ProfileShowcase()
}
@Test
fun themeSelection_default_light() = runScreen("themes_default_light", AppThemes.Default, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_default_dark() = runScreen("themes_default_dark", AppThemes.Default, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_ocean_light() = runScreen("themes_ocean_light", AppThemes.Ocean, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_ocean_dark() = runScreen("themes_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_midnight_light() = runScreen("themes_midnight_light", AppThemes.Midnight, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_midnight_dark() = runScreen("themes_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun completeTask_default_light() = runScreen("complete_task_default_light", AppThemes.Default, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_default_dark() = runScreen("complete_task_default_dark", AppThemes.Default, darkTheme = true) {
CompleteTaskShowcase()
}
@Test
fun completeTask_ocean_light() = runScreen("complete_task_ocean_light", AppThemes.Ocean, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_ocean_dark() = runScreen("complete_task_ocean_dark", AppThemes.Ocean, darkTheme = true) {
CompleteTaskShowcase()
}
@Test
fun completeTask_midnight_light() = runScreen("complete_task_midnight_light", AppThemes.Midnight, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_midnight_dark() = runScreen("complete_task_midnight_dark", AppThemes.Midnight, darkTheme = true) {
CompleteTaskShowcase()
}
// ---------- Shared runner ----------
private fun runScreen(
name: String,
theme: ThemeColors,
darkTheme: Boolean,
content: @Composable () -> Unit,
) {
captureRoboImage(
filePath = "build/outputs/roborazzi/$name.png",
roborazziOptions = RoborazziOptions(),
) {
HoneyDueTheme(darkTheme = darkTheme, themeColors = theme) {
content()
variants.forEach { variant ->
val fileName = "${surface.name}${variant.fileSuffix}.png"
val fixture = variant.dataManager()
seedSingleton(fixture)
// Flush the main-thread Looper so any `stateIn(... Eagerly)`
// collectors on VMs reused across captures have processed the
// DataManager update before we snapshot. Without this, VMs
// might see the previous variant's data because coroutine
// emissions race the capture call.
shadowOf(android.os.Looper.getMainLooper()).idle()
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
HoneyDueTheme(
themeColors = AppThemes.Default,
darkTheme = variant.darkTheme,
) {
// `LocalInspectionMode = true` signals to production
// composables that they're rendering in a hermetic
// preview/test environment. Camera pickers, gated push
// registrations, and animation callbacks use this flag
// to short-circuit calls that require real Android
// subsystems (e.g. `FileProvider` paths that aren't
// resolvable under Robolectric's test data dir).
CompositionLocalProvider(
LocalDataManager provides fixture,
LocalInspectionMode provides true,
) {
Box(Modifier.fillMaxSize()) {
surface.content()
}
}
}
}
}
// Reset after suite so other tests don't inherit state.
com.tt.honeyDue.data.DataManager.setSubscription(null)
}
/**
* Mirror every StateFlow on `fixture` onto the `DataManager` singleton
* so code paths that bypass `LocalDataManager` (screens that call
* `DataManager.x` directly, VMs whose default-arg resolves to the
* singleton, `SubscriptionHelper` free-tier gate) see the same data.
*
* Critical: clear the singleton first so the previous variant's
* writes don't leak into this variant's `empty` render.
*/
private fun seedSingleton(fixture: IDataManager) {
val dm = com.tt.honeyDue.data.DataManager
dm.clear()
dm.setSubscription(fixture.subscription.value)
dm.setCurrentUser(fixture.currentUser.value)
fixture.myResidences.value?.let { dm.setMyResidences(it) }
dm.setResidences(fixture.residences.value)
fixture.totalSummary.value?.let { dm.setTotalSummary(it) }
fixture.allTasks.value?.let { dm.setAllTasks(it) }
dm.setDocuments(fixture.documents.value)
dm.setContractors(fixture.contractors.value)
dm.setFeatureBenefits(fixture.featureBenefits.value)
dm.setUpgradeTriggers(fixture.upgradeTriggers.value)
dm.setTaskCategories(fixture.taskCategories.value)
dm.setTaskPriorities(fixture.taskPriorities.value)
dm.setTaskFrequencies(fixture.taskFrequencies.value)
fixture.contractorsByResidence.value.forEach { (rid, list) ->
dm.setContractorsForResidence(rid, list)
}
fixture.contractorDetail.value.values.forEach { dm.setContractorDetail(it) }
fixture.documentDetail.value.values.forEach { dm.setDocumentDetail(it) }
fixture.taskCompletions.value.forEach { (taskId, completions) ->
dm.setTaskCompletions(taskId, completions)
}
fixture.tasksByResidence.value.forEach { (rid, cols) ->
dm.setTasksForResidence(rid, cols)
}
fixture.notificationPreferences.value?.let { dm.setNotificationPreferences(it) }
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun surfaces(): List<Array<Any>> =
gallerySurfaces.map { arrayOf<Any>(it) }
}
}
// ============ Theme-agnostic showcase composables ============
//
// Each mirrors the *surface* (not the full data pipeline) of its named
// production screen. This keeps Roborazzi tests hermetic — no Ktor
// client, no DataManager, no ViewModel — while still exercising every
// colour slot in the MaterialTheme that ships with the app.
@Composable
private fun LoginShowcase() {
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
"honeyDue",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Keep your home running",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(value = "testuser", onValueChange = {}, label = { Text("Username") })
OutlinedTextField(value = "•••••••••", onValueChange = {}, label = { Text("Password") })
Button(onClick = {}, modifier = Modifier.fillMaxSize(1f)) {
Text("Sign In")
}
TextButton(onClick = {}) { Text("Forgot password?") }
}
}
}
@Composable
private fun TasksShowcase() {
Scaffold(topBar = {
TopAppBar(
title = { Text("Tasks") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
/**
* One render-variant captured per surface. The `dataManager` factory is
* invoked lazily so each capture gets a pristine fixture (avoiding
* cross-test StateFlow mutation).
*
* @property fileSuffix Appended to the surface name to form the PNG
* filename. Includes a leading `_`. Examples: `_empty_light`,
* `_populated_dark`, `_light`, `_dark`.
*/
private data class Variant(
val fileSuffix: String,
val darkTheme: Boolean,
val dataManager: () -> IDataManager,
) {
companion object {
/**
* DataCarrying surfaces: 4 variants. `empty` captures pass
* `seedLookups = false` so form dropdowns render empty in the
* empty-variant PNGs — letting screens that read lookups produce
* a visible diff against the populated variant.
*/
val dataCarrying: List<Variant> = listOf(
Variant("_empty_light", darkTheme = false) {
FixtureDataManager.empty(seedLookups = false)
},
Variant("_empty_dark", darkTheme = true) {
FixtureDataManager.empty(seedLookups = false)
},
Variant("_populated_light", darkTheme = false) { FixtureDataManager.populated() },
Variant("_populated_dark", darkTheme = true) { FixtureDataManager.populated() },
)
}) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
listOf("Replace HVAC filter", "Test smoke alarms", "Clean gutters").forEach { title ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Task, null, tint = MaterialTheme.colorScheme.primary)
Text(title, style = MaterialTheme.typography.bodyLarge)
}
}
}
Button(onClick = {}, colors = ButtonDefaults.buttonColors()) {
Icon(Icons.Filled.Add, null)
Text("New task", modifier = Modifier.padding(start = 8.dp))
}
}
}
}
@Composable
private fun ResidencesShowcase() {
Scaffold(topBar = {
TopAppBar(title = { Text("Residences") })
}) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Icons.Filled.Home, null, tint = MaterialTheme.colorScheme.primary)
Text("Primary Home", style = MaterialTheme.typography.titleMedium)
}
Text(
"1234 Sunflower Lane",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedButton(onClick = {}) { Text("Add residence") }
}
}
}
@Composable
private fun ProfileShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Profile") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"testuser",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"claude@treymail.com",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
listOf("Notifications", "Theme", "Help").forEach { label ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Text(label, modifier = Modifier.padding(16.dp))
}
}
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) { Text("Log out") }
}
}
}
@Composable
private fun ThemePaletteShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Theme") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
"Primary" to MaterialTheme.colorScheme.primary,
"Secondary" to MaterialTheme.colorScheme.secondary,
"Tertiary" to MaterialTheme.colorScheme.tertiary,
"Surface" to MaterialTheme.colorScheme.surface,
"Error" to MaterialTheme.colorScheme.error,
).forEach { (label, color) ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Card(
colors = CardDefaults.cardColors(containerColor = color),
shape = RoundedCornerShape(8.dp),
) {
Column(Modifier.padding(24.dp)) { Text(" ", color = Color.Transparent) }
}
Text(label, color = MaterialTheme.colorScheme.onBackground)
}
}
}
}
}
@Composable
private fun CompleteTaskShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Complete Task") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Test smoke alarms", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(value = "42.50", onValueChange = {}, label = { Text("Actual cost") })
OutlinedTextField(value = "All alarms passed.", onValueChange = {}, label = { Text("Notes") })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {}) { Text("Cancel") }
Button(onClick = {}) { Text("Mark complete") }
}
}
/**
* DataFree surfaces: 2 variants (light/dark only). Lookups are
* seeded because forms expect them to be present in production
* (a user with zero entities still sees the priority picker).
* The populated variant is deliberately omitted — DataFree
* surfaces render no entity data, so `populated` would be
* byte-identical to `empty`.
*/
val dataFree: List<Variant> = listOf(
Variant("_light", darkTheme = false) { FixtureDataManager.empty(seedLookups = true) },
Variant("_dark", darkTheme = true) { FixtureDataManager.empty(seedLookups = true) },
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Some files were not shown because too many files have changed in this diff Show More