Compare commits

...

14 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
408 changed files with 4003 additions and 1104 deletions

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,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

@@ -2,6 +2,7 @@
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
@@ -25,11 +26,10 @@ 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.TasksScreen
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
import com.tt.honeyDue.ui.screens.dev.AnimationTestingScreen
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
@@ -40,34 +40,44 @@ 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 primary screen in the app that the parity
* gallery captures. Each entry renders the production composable directly
* the screen reads its data from [com.tt.honeyDue.data.LocalDataManager],
* which the capture driver overrides with a [com.tt.honeyDue.testing.FixtureDataManager]
* (empty or populated) per variant.
* 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 listed under `category: shared` in
* docs/ios-parity/screens.json (loaders, error rows, thumbnails — they
* - shared helper composables (loaders, error rows, thumbnails — they
* only appear as part of a parent screen).
*
* 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.
* 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. */
@@ -177,6 +187,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
onJoined = {},
)
},
GallerySurface("onboarding_first_task") {
OnboardingFirstTaskContent(
viewModel = OnboardingViewModel(),
onTasksAdded = {},
)
},
GallerySurface("onboarding_subscription") {
OnboardingSubscriptionContent(
onSubscribe = {},
@@ -184,7 +200,7 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
// ---------- Home / main navigation ----------
// ---------- Home (Android-only dashboard) ----------
GallerySurface("home") {
HomeScreen(
onNavigateToResidences = {},
@@ -240,12 +256,16 @@ val gallerySurfaces: List<GallerySurface> = listOf(
},
// ---------- Tasks ----------
GallerySurface("tasks") {
TasksScreen(onNavigateBack = {})
},
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(),
@@ -285,9 +305,20 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
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 = Fixtures.contractors.first().id,
contractorId = id,
onNavigateBack = {},
viewModel = vm,
)
},
@@ -299,10 +330,13 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
GallerySurface("document_detail") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
DocumentDetailScreen(
documentId = Fixtures.documents.first().id ?: 0,
documentId = id,
onNavigateBack = {},
onNavigateToEdit = {},
onNavigateToEdit = { _ -> },
documentViewModel = vm,
)
},
GallerySurface("add_document") {
@@ -313,9 +347,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
GallerySurface("edit_document") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
EditDocumentScreen(
documentId = Fixtures.documents.first().id ?: 0,
documentId = id,
onNavigateBack = {},
documentViewModel = vm,
)
},
@@ -326,14 +363,11 @@ val gallerySurfaces: List<GallerySurface> = listOf(
onLogout = {},
)
},
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("notification_preferences") {
NotificationPreferencesScreen(onNavigateBack = {})
},
GallerySurface("animation_testing") {
AnimationTestingScreen(onNavigateBack = {})
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("biometric_lock") {
BiometricLockScreen(onUnlocked = {})

View File

@@ -6,54 +6,60 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
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 org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Parity-gallery Roborazzi snapshot tests (P2).
* Parity-gallery Roborazzi snapshot tests.
*
* For every entry in [gallerySurfaces] we capture four variants:
* empty × light, empty × dark, populated × light, populated × dark
* Variant matrix (driven by [GalleryCategory] in the canonical
* [GalleryScreens] manifest):
*
* Per surface that's 4 PNGs × ~40 surfaces ≈ 160 goldens. Paired with the
* iOS swift-snapshot-testing gallery (P3) that captures the same set of
* (screen, data, theme) tuples, any visual divergence between the two
* platforms surfaces here as a golden diff rather than silently shipping.
* 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)
*
* How this differs from the showcase tests that lived here before:
* - Showcases rendered hand-crafted theme-agnostic surfaces; now we
* render the actual production composables (`LoginScreen(…)`, etc.)
* through the fixture-backed [LocalDataManager].
* - Surfaces are declared in [GallerySurfaces.kt] instead of being
* inlined, so adding a new screen is a one-line change.
* - Previously 6 surfaces × 3 themes × 2 modes; now the matrix is
* N surfaces × {empty, populated} × {light, dark} — themes beyond
* the default are intentionally out of scope (theme variation is
* covered by the dedicated theme_selection surface).
* DataFree surfaces — capture 2 variants:
* surface_light.png (empty fixture, lookups seeded, light)
* surface_dark.png (empty fixture, lookups seeded, dark)
*
* One parameterized test per surface gives granular CI failures — the
* 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; we never have to copy
* between a transient `build/outputs/roborazzi/` and the committed
* fixture directory (which was the source of the pre-existing
* "original file was not found" failure).
* the same directory where goldens are committed means record and
* verify round-trip through one canonical location.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -64,24 +70,15 @@ class ScreenshotTests(
/**
* Compose Multiplatform's `stringResource()` loads text via a
* JVM-static context held by `AndroidContextProvider`. In a real APK
* that ContentProvider is registered in the manifest and populated at
* app start; under Robolectric unit tests it never runs, so every
* `stringResource(...)` call throws "Android context is not
* initialized."
*
* `PreviewContextConfigurationEffect()` is the documented fix — but
* it only fires inside `LocalInspectionMode = true`, and even then
* the first composition frame renders before the effect lands, so
* `stringResource()` calls race the context set.
* 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` in Kotlin, so we can't
* touch its class directly — but its static slot is writable
* through the generated `Companion.setANDROID_CONTEXT` accessor.
* `@Before` runs inside the Robolectric sandbox (where
* `ApplicationProvider` is valid); `@BeforeClass` would run outside
* it and fail with "No instrumentation registered!".
* `AndroidContextProvider` is `internal`, but its static slot is
* writable through the generated `Companion.setANDROID_CONTEXT`
* accessor.
*/
@Before
fun bootstrapComposeResources() {
@@ -95,14 +92,44 @@ class ScreenshotTests(
@Test
fun captureAllVariants() {
Variant.all().forEach { variant ->
val fileName = "${surface.name}_${variant.state}_${variant.mode}.png"
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.",
)
val variants = when (screen.category) {
GalleryCategory.DataCarrying -> Variant.dataCarrying
GalleryCategory.DataFree -> Variant.dataFree
}
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,
) {
CompositionLocalProvider(LocalDataManager provides variant.dataManager()) {
// `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()
}
@@ -110,6 +137,47 @@ class ScreenshotTests(
}
}
}
// 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 {
@@ -121,23 +189,48 @@ class ScreenshotTests(
}
/**
* One of the four render-variants captured per surface. The
* `dataManager` factory is invoked lazily so each capture gets its own
* pristine fixture (avoiding cross-test StateFlow mutation).
* 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 state: String,
val mode: String,
val fileSuffix: String,
val darkTheme: Boolean,
val dataManager: () -> IDataManager,
) {
companion object {
fun all(): List<Variant> = listOf(
Variant("empty", "light", darkTheme = false) { FixtureDataManager.empty() },
Variant("empty", "dark", darkTheme = true) { FixtureDataManager.empty() },
Variant("populated", "light", darkTheme = false) { FixtureDataManager.populated() },
Variant("populated", "dark", darkTheme = true) { FixtureDataManager.populated() },
/**
* 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() },
)
/**
* 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

View File

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

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.

After

Width:  |  Height:  |  Size: 92 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

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

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.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 78 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

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.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 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.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

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