Compare commits

...

12 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
409 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.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import java.io.File import java.io.File
@@ -58,6 +59,15 @@ actual fun rememberImagePicker(
actual fun rememberCameraPicker( actual fun rememberCameraPicker(
onImageCaptured: (ImageData) -> Unit onImageCaptured: (ImageData) -> Unit
): () -> 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 val context = LocalContext.current
// Create a temp file URI for the camera to save to // 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 package com.tt.honeyDue.screenshot
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.tt.honeyDue.testing.Fixtures import com.tt.honeyDue.testing.Fixtures
import com.tt.honeyDue.ui.screens.AddDocumentScreen import com.tt.honeyDue.ui.screens.AddDocumentScreen
import com.tt.honeyDue.ui.screens.AddResidenceScreen 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.ResetPasswordScreen
import com.tt.honeyDue.ui.screens.ResidenceDetailScreen import com.tt.honeyDue.ui.screens.ResidenceDetailScreen
import com.tt.honeyDue.ui.screens.ResidencesScreen 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.VerifyEmailScreen
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen 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.OnboardingCreateAccountContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingFirstTaskContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingHomeProfileContent import com.tt.honeyDue.ui.screens.onboarding.OnboardingHomeProfileContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingJoinResidenceContent import com.tt.honeyDue.ui.screens.onboarding.OnboardingJoinResidenceContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingLocationContent 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.onboarding.OnboardingWelcomeContent
import com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen import com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen
import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen 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.TaskSuggestionsScreen
import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen 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.OnboardingViewModel
import com.tt.honeyDue.viewmodel.PasswordResetViewModel import com.tt.honeyDue.viewmodel.PasswordResetViewModel
/** /**
* Declarative manifest of every primary screen in the app that the parity * Declarative manifest of every Android gallery surface. Must stay in sync
* gallery captures. Each entry renders the production composable directly * with the canonical [com.tt.honeyDue.testing.GalleryScreens] manifest
* the screen reads its data from [com.tt.honeyDue.data.LocalDataManager], * [GalleryManifestParityTest] fails CI if the two drift.
* which the capture driver overrides with a [com.tt.honeyDue.testing.FixtureDataManager]
* (empty or populated) per variant.
* *
* Scope: the screens users land on. We deliberately skip: * Scope: the screens users land on. We deliberately skip:
* - dialogs that live inside a host screen (already captured on the host), * - dialogs that live inside a host screen (already captured on the host),
* - animation sub-views / decorative components in AnimationTesting/, * - animation sub-views / decorative components in AnimationTesting/,
* - widget views (Android Glance / iOS WidgetKit — separate surface), * - widget views (Android Glance / iOS WidgetKit — separate surface),
* - shared helper composables listed under `category: shared` in * - shared helper composables (loaders, error rows, thumbnails — they
* docs/ios-parity/screens.json (loaders, error rows, thumbnails — they
* only appear as part of a parent screen). * only appear as part of a parent screen).
* *
* Screens that require a construction-time ViewModel (`OnboardingViewModel`, * Detail-VM pattern (contractor_detail, document_detail, edit_document):
* `PasswordResetViewModel`) instantiate it inline here. The production code * the VM is created with the fixture id already pre-selected, so
* paths start the viewmodel's own `launch { APILayer.xxx() }` on first * `stateIn(SharingStarted.Eagerly, initialValue = dataManager.x[id])`
* composition — those calls fail fast in the hermetic Robolectric * emits `Success(entity)` on first composition. Without this pre-select,
* environment, but the composition itself renders the surface from the * the screens' own `LaunchedEffect(id) { vm.loadX(id) }` dispatches the id
* injected [com.tt.honeyDue.data.LocalDataManager] before any network * assignment to a coroutine that runs *after* Roborazzi captures the
* result arrives, which is exactly what we want to compare against iOS. * 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( data class GallerySurface(
/** Snake-case identifier; used as the golden file-name prefix. */ /** Snake-case identifier; used as the golden file-name prefix. */
@@ -177,6 +187,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
onJoined = {}, onJoined = {},
) )
}, },
GallerySurface("onboarding_first_task") {
OnboardingFirstTaskContent(
viewModel = OnboardingViewModel(),
onTasksAdded = {},
)
},
GallerySurface("onboarding_subscription") { GallerySurface("onboarding_subscription") {
OnboardingSubscriptionContent( OnboardingSubscriptionContent(
onSubscribe = {}, onSubscribe = {},
@@ -184,7 +200,7 @@ val gallerySurfaces: List<GallerySurface> = listOf(
) )
}, },
// ---------- Home / main navigation ---------- // ---------- Home (Android-only dashboard) ----------
GallerySurface("home") { GallerySurface("home") {
HomeScreen( HomeScreen(
onNavigateToResidences = {}, onNavigateToResidences = {},
@@ -240,12 +256,16 @@ val gallerySurfaces: List<GallerySurface> = listOf(
}, },
// ---------- Tasks ---------- // ---------- Tasks ----------
GallerySurface("tasks") {
TasksScreen(onNavigateBack = {})
},
GallerySurface("all_tasks") { GallerySurface("all_tasks") {
AllTasksScreen(onNavigateToEditTask = {}) AllTasksScreen(onNavigateToEditTask = {})
}, },
GallerySurface("add_task_with_residence") {
AddTaskWithResidenceScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onCreated = {},
)
},
GallerySurface("edit_task") { GallerySurface("edit_task") {
EditTaskScreen( EditTaskScreen(
task = Fixtures.tasks.first(), task = Fixtures.tasks.first(),
@@ -285,9 +305,20 @@ val gallerySurfaces: List<GallerySurface> = listOf(
) )
}, },
GallerySurface("contractor_detail") { 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( ContractorDetailScreen(
contractorId = Fixtures.contractors.first().id, contractorId = id,
onNavigateBack = {}, onNavigateBack = {},
viewModel = vm,
) )
}, },
@@ -299,10 +330,13 @@ val gallerySurfaces: List<GallerySurface> = listOf(
) )
}, },
GallerySurface("document_detail") { GallerySurface("document_detail") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
DocumentDetailScreen( DocumentDetailScreen(
documentId = Fixtures.documents.first().id ?: 0, documentId = id,
onNavigateBack = {}, onNavigateBack = {},
onNavigateToEdit = {}, onNavigateToEdit = { _ -> },
documentViewModel = vm,
) )
}, },
GallerySurface("add_document") { GallerySurface("add_document") {
@@ -313,9 +347,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
) )
}, },
GallerySurface("edit_document") { GallerySurface("edit_document") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
EditDocumentScreen( EditDocumentScreen(
documentId = Fixtures.documents.first().id ?: 0, documentId = id,
onNavigateBack = {}, onNavigateBack = {},
documentViewModel = vm,
) )
}, },
@@ -326,14 +363,11 @@ val gallerySurfaces: List<GallerySurface> = listOf(
onLogout = {}, onLogout = {},
) )
}, },
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("notification_preferences") { GallerySurface("notification_preferences") {
NotificationPreferencesScreen(onNavigateBack = {}) NotificationPreferencesScreen(onNavigateBack = {})
}, },
GallerySurface("animation_testing") { GallerySurface("theme_selection") {
AnimationTestingScreen(onNavigateBack = {}) ThemeSelectionScreen(onNavigateBack = {})
}, },
GallerySurface("biometric_lock") { GallerySurface("biometric_lock") {
BiometricLockScreen(onUnlocked = {}) BiometricLockScreen(onUnlocked = {})

View File

@@ -6,54 +6,60 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage import com.github.takahirom.roborazzi.captureRoboImage
import com.tt.honeyDue.data.IDataManager import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.data.LocalDataManager import com.tt.honeyDue.data.LocalDataManager
import com.tt.honeyDue.testing.FixtureDataManager 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.AppThemes
import com.tt.honeyDue.ui.theme.HoneyDueTheme import com.tt.honeyDue.ui.theme.HoneyDueTheme
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode 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: * Variant matrix (driven by [GalleryCategory] in the canonical
* empty × light, empty × dark, populated × light, populated × dark * [GalleryScreens] manifest):
* *
* Per surface that's 4 PNGs × ~40 surfaces ≈ 160 goldens. Paired with the * DataCarrying surfaces — capture 4 variants:
* iOS swift-snapshot-testing gallery (P3) that captures the same set of * surface_empty_light.png (empty fixture, no lookups, light)
* (screen, data, theme) tuples, any visual divergence between the two * surface_empty_dark.png (empty fixture, no lookups, dark)
* platforms surfaces here as a golden diff rather than silently shipping. * 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: * DataFree surfaces — capture 2 variants:
* - Showcases rendered hand-crafted theme-agnostic surfaces; now we * surface_light.png (empty fixture, lookups seeded, light)
* render the actual production composables (`LoginScreen(…)`, etc.) * surface_dark.png (empty fixture, lookups seeded, dark)
* 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).
* *
* 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. * report shows `ScreenshotTests[login]`, `ScreenshotTests[tasks]`, etc.
* rather than one monolithic failure when any surface drifts. * rather than one monolithic failure when any surface drifts.
* *
* Why the goldens land directly under `src/androidUnitTest/roborazzi/`: * Why the goldens land directly under `src/androidUnitTest/roborazzi/`:
* Roborazzi resolves `captureRoboImage(filePath = …)` relative to the * Roborazzi resolves `captureRoboImage(filePath = …)` relative to the
* Gradle test task's working directory (the module root). Writing to * Gradle test task's working directory (the module root). Writing to
* the same directory where goldens are committed means record and verify * the same directory where goldens are committed means record and
* round-trip through one canonical location; we never have to copy * verify round-trip through one canonical location.
* 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).
*/ */
@RunWith(ParameterizedRobolectricTestRunner::class) @RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -64,24 +70,15 @@ class ScreenshotTests(
/** /**
* Compose Multiplatform's `stringResource()` loads text via a * Compose Multiplatform's `stringResource()` loads text via a
* JVM-static context held by `AndroidContextProvider`. In a real APK * JVM-static context held by `AndroidContextProvider`. Under
* that ContentProvider is registered in the manifest and populated at * Robolectric unit tests the `ContentProvider` that normally
* app start; under Robolectric unit tests it never runs, so every * populates it never runs, so every `stringResource(...)` call throws
* `stringResource(...)` call throws "Android context is not * "Android context is not initialized."
* 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.
* *
* Install the context eagerly via reflection before each test. * Install the context eagerly via reflection before each test.
* `AndroidContextProvider` is `internal` in Kotlin, so we can't * `AndroidContextProvider` is `internal`, but its static slot is
* touch its class directly — but its static slot is writable * writable through the generated `Companion.setANDROID_CONTEXT`
* through the generated `Companion.setANDROID_CONTEXT` accessor. * accessor.
* `@Before` runs inside the Robolectric sandbox (where
* `ApplicationProvider` is valid); `@BeforeClass` would run outside
* it and fail with "No instrumentation registered!".
*/ */
@Before @Before
fun bootstrapComposeResources() { fun bootstrapComposeResources() {
@@ -95,14 +92,44 @@ class ScreenshotTests(
@Test @Test
fun captureAllVariants() { fun captureAllVariants() {
Variant.all().forEach { variant -> val screen = GalleryScreens.forAndroid[surface.name]
val fileName = "${surface.name}_${variant.state}_${variant.mode}.png" ?: 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") { captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
HoneyDueTheme( HoneyDueTheme(
themeColors = AppThemes.Default, themeColors = AppThemes.Default,
darkTheme = variant.darkTheme, 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()) { Box(Modifier.fillMaxSize()) {
surface.content() 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 { companion object {
@@ -121,23 +189,48 @@ class ScreenshotTests(
} }
/** /**
* One of the four render-variants captured per surface. The * One render-variant captured per surface. The `dataManager` factory is
* `dataManager` factory is invoked lazily so each capture gets its own * invoked lazily so each capture gets a pristine fixture (avoiding
* pristine fixture (avoiding cross-test StateFlow mutation). * 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( private data class Variant(
val state: String, val fileSuffix: String,
val mode: String,
val darkTheme: Boolean, val darkTheme: Boolean,
val dataManager: () -> IDataManager, val dataManager: () -> IDataManager,
) { ) {
companion object { companion object {
fun all(): List<Variant> = listOf( /**
Variant("empty", "light", darkTheme = false) { FixtureDataManager.empty() }, * DataCarrying surfaces: 4 variants. `empty` captures pass
Variant("empty", "dark", darkTheme = true) { FixtureDataManager.empty() }, * `seedLookups = false` so form dropdowns render empty in the
Variant("populated", "light", darkTheme = false) { FixtureDataManager.populated() }, * empty-variant PNGs — letting screens that read lookups produce
Variant("populated", "dark", darkTheme = true) { FixtureDataManager.populated() }, * 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