Compare commits

...

10 Commits

Author SHA1 Message Date
Trey T
fec0c4384a docs: regenerate parity gallery HTML (37 screens, 89 Android + 58 iOS) 2026-04-18 23:50:37 -05:00
Trey T
7a04ad4ff2 P2 addendum: 18 additional Android goldens (add/edit residence, join, manage users)
Late writes from the previous recordRoborazziDebug pass. Brings Android
coverage from 17 → 21 surfaces.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

55
Makefile Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,349 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.tt.honeyDue.screenshot
import androidx.compose.runtime.Composable
import com.tt.honeyDue.testing.Fixtures
import com.tt.honeyDue.ui.screens.AddDocumentScreen
import com.tt.honeyDue.ui.screens.AddResidenceScreen
import com.tt.honeyDue.ui.screens.AllTasksScreen
import com.tt.honeyDue.ui.screens.BiometricLockScreen
import com.tt.honeyDue.ui.screens.CompleteTaskScreen
import com.tt.honeyDue.ui.screens.ContractorDetailScreen
import com.tt.honeyDue.ui.screens.ContractorsScreen
import com.tt.honeyDue.ui.screens.DocumentDetailScreen
import com.tt.honeyDue.ui.screens.DocumentsScreen
import com.tt.honeyDue.ui.screens.EditDocumentScreen
import com.tt.honeyDue.ui.screens.EditResidenceScreen
import com.tt.honeyDue.ui.screens.EditTaskScreen
import com.tt.honeyDue.ui.screens.ForgotPasswordScreen
import com.tt.honeyDue.ui.screens.HomeScreen
import com.tt.honeyDue.ui.screens.LoginScreen
import com.tt.honeyDue.ui.screens.ManageUsersScreen
import com.tt.honeyDue.ui.screens.NotificationPreferencesScreen
import com.tt.honeyDue.ui.screens.ProfileScreen
import com.tt.honeyDue.ui.screens.RegisterScreen
import com.tt.honeyDue.ui.screens.ResetPasswordScreen
import com.tt.honeyDue.ui.screens.ResidenceDetailScreen
import com.tt.honeyDue.ui.screens.ResidencesScreen
import com.tt.honeyDue.ui.screens.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.OnboardingHomeProfileContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingJoinResidenceContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingLocationContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingNameResidenceContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingSubscriptionContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingValuePropsContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingVerifyEmailContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingWelcomeContent
import com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen
import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen
import com.tt.honeyDue.ui.screens.task.TaskSuggestionsScreen
import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
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.
*
* 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
* 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.
*/
data class GallerySurface(
/** Snake-case identifier; used as the golden file-name prefix. */
val name: String,
val content: @Composable () -> Unit,
) {
/**
* ParameterizedRobolectricTestRunner uses `toString()` in the test
* display name when the `{0}` pattern is set. The default data-class
* toString includes the composable lambda hash — not useful. Override
* so test reports show `ScreenshotTests[login]` instead of
* `ScreenshotTests[GallerySurface(name=login, content=...@abc123)]`.
*/
override fun toString(): String = name
}
val gallerySurfaces: List<GallerySurface> = listOf(
// ---------- Auth ----------
GallerySurface("login") {
LoginScreen(
onLoginSuccess = {},
onNavigateToRegister = {},
onNavigateToForgotPassword = {},
)
},
GallerySurface("register") {
RegisterScreen(
onRegisterSuccess = {},
onNavigateBack = {},
)
},
GallerySurface("forgot_password") {
ForgotPasswordScreen(
onNavigateBack = {},
onNavigateToVerify = {},
onNavigateToReset = {},
viewModel = PasswordResetViewModel(),
)
},
GallerySurface("verify_reset_code") {
VerifyResetCodeScreen(
onNavigateBack = {},
onNavigateToReset = {},
viewModel = PasswordResetViewModel(),
)
},
GallerySurface("reset_password") {
ResetPasswordScreen(
onPasswordResetSuccess = {},
onNavigateBack = {},
viewModel = PasswordResetViewModel(),
)
},
GallerySurface("verify_email") {
VerifyEmailScreen(
onVerifySuccess = {},
onLogout = {},
)
},
// ---------- Onboarding ----------
GallerySurface("onboarding_welcome") {
OnboardingWelcomeContent(
onStartFresh = {},
onJoinExisting = {},
onLogin = {},
)
},
GallerySurface("onboarding_value_props") {
OnboardingValuePropsContent(onContinue = {})
},
GallerySurface("onboarding_create_account") {
OnboardingCreateAccountContent(
viewModel = OnboardingViewModel(),
onAccountCreated = {},
)
},
GallerySurface("onboarding_verify_email") {
OnboardingVerifyEmailContent(
viewModel = OnboardingViewModel(),
onVerified = {},
)
},
GallerySurface("onboarding_location") {
OnboardingLocationContent(
viewModel = OnboardingViewModel(),
onLocationDetected = {},
onSkip = {},
)
},
GallerySurface("onboarding_name_residence") {
OnboardingNameResidenceContent(
viewModel = OnboardingViewModel(),
onContinue = {},
)
},
GallerySurface("onboarding_home_profile") {
OnboardingHomeProfileContent(
viewModel = OnboardingViewModel(),
onContinue = {},
onSkip = {},
)
},
GallerySurface("onboarding_join_residence") {
OnboardingJoinResidenceContent(
viewModel = OnboardingViewModel(),
onJoined = {},
)
},
GallerySurface("onboarding_subscription") {
OnboardingSubscriptionContent(
onSubscribe = {},
onSkip = {},
)
},
// ---------- Home / main navigation ----------
GallerySurface("home") {
HomeScreen(
onNavigateToResidences = {},
onNavigateToTasks = {},
onLogout = {},
)
},
// ---------- Residences ----------
GallerySurface("residences") {
ResidencesScreen(
onResidenceClick = {},
onAddResidence = {},
onJoinResidence = {},
onLogout = {},
)
},
GallerySurface("residence_detail") {
ResidenceDetailScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onNavigateToEditResidence = {},
onNavigateToEditTask = {},
)
},
GallerySurface("add_residence") {
AddResidenceScreen(
onNavigateBack = {},
onResidenceCreated = {},
)
},
GallerySurface("edit_residence") {
EditResidenceScreen(
residence = Fixtures.primaryHome,
onNavigateBack = {},
onResidenceUpdated = {},
)
},
GallerySurface("join_residence") {
JoinResidenceScreen(
onNavigateBack = {},
onJoined = {},
)
},
GallerySurface("manage_users") {
ManageUsersScreen(
residenceId = Fixtures.primaryHome.id,
residenceName = Fixtures.primaryHome.name,
isPrimaryOwner = true,
residenceOwnerId = Fixtures.primaryHome.ownerId,
onNavigateBack = {},
)
},
// ---------- Tasks ----------
GallerySurface("tasks") {
TasksScreen(onNavigateBack = {})
},
GallerySurface("all_tasks") {
AllTasksScreen(onNavigateToEditTask = {})
},
GallerySurface("edit_task") {
EditTaskScreen(
task = Fixtures.tasks.first(),
onNavigateBack = {},
onTaskUpdated = {},
)
},
GallerySurface("complete_task") {
val task = Fixtures.tasks.first()
CompleteTaskScreen(
taskId = task.id,
taskTitle = task.title,
residenceName = Fixtures.primaryHome.name,
onNavigateBack = {},
onComplete = { _, _ -> },
)
},
GallerySurface("task_suggestions") {
TaskSuggestionsScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onSuggestionAccepted = {},
)
},
GallerySurface("task_templates_browser") {
TaskTemplatesBrowserScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
)
},
// ---------- Contractors ----------
GallerySurface("contractors") {
ContractorsScreen(
onNavigateBack = {},
onNavigateToContractorDetail = {},
)
},
GallerySurface("contractor_detail") {
ContractorDetailScreen(
contractorId = Fixtures.contractors.first().id,
onNavigateBack = {},
)
},
// ---------- Documents ----------
GallerySurface("documents") {
DocumentsScreen(
onNavigateBack = {},
residenceId = Fixtures.primaryHome.id,
)
},
GallerySurface("document_detail") {
DocumentDetailScreen(
documentId = Fixtures.documents.first().id ?: 0,
onNavigateBack = {},
onNavigateToEdit = {},
)
},
GallerySurface("add_document") {
AddDocumentScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onDocumentCreated = {},
)
},
GallerySurface("edit_document") {
EditDocumentScreen(
documentId = Fixtures.documents.first().id ?: 0,
onNavigateBack = {},
)
},
// ---------- Profile / settings ----------
GallerySurface("profile") {
ProfileScreen(
onNavigateBack = {},
onLogout = {},
)
},
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("notification_preferences") {
NotificationPreferencesScreen(onNavigateBack = {})
},
GallerySurface("animation_testing") {
AnimationTestingScreen(onNavigateBack = {})
},
GallerySurface("biometric_lock") {
BiometricLockScreen(onUnlocked = {})
},
// ---------- Subscription ----------
GallerySurface("feature_comparison") {
FeatureComparisonScreen(
onNavigateBack = {},
onNavigateToUpgrade = {},
)
},
)

View File

@@ -1,485 +1,143 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.tt.honeyDue.screenshot
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Task
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.material3.ExperimentalMaterial3Api
import com.github.takahirom.roborazzi.RoborazziOptions
import androidx.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.ui.theme.AppThemes
import com.tt.honeyDue.ui.theme.HoneyDueTheme
import com.tt.honeyDue.ui.theme.ThemeColors
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Roborazzi-driven screenshot regression tests (P8).
* Parity-gallery Roborazzi snapshot tests (P2).
*
* Runs entirely on the Robolectric unit-test classpath — no emulator
* required. The goal is to catch accidental UI drift (colour, spacing,
* typography) on PRs by diffing generated PNGs against a committed
* golden set.
* For every entry in [gallerySurfaces] we capture four variants:
* empty × light, empty × dark, populated × light, populated × dark
*
* Matrix: 6 surfaces × 3 themes (Default / Ocean / Midnight) × 2 modes
* (light / dark) = 36 images. This is a conservative baseline; the full
* 11-theme matrix would produce 132+ images and is deferred.
* 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.
*
* Implementation notes:
* - We use the top-level `captureRoboImage(path) { composable }` form
* from roborazzi-compose. That helper registers
* `RoborazziTransparentActivity` at runtime via Robolectric's shadow
* PackageManager, so we don't need `createComposeRule()` /
* `ActivityScenarioRule<ComponentActivity>` and therefore avoid the
* "Unable to resolve activity for Intent MAIN/LAUNCHER cmp=.../ComponentActivity"
* failure that bit the initial scaffolding (RoboMonitoringInstrumentation:102).
* - Goldens land under `composeApp/build/outputs/roborazzi/`, which the
* Roborazzi Gradle plugin picks up for record / verify / compare.
* 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).
*
* Workflow:
* - Initial record: `./gradlew :composeApp:recordRoborazziDebug`
* - Verify in CI: `./gradlew :composeApp:verifyRoborazziDebug`
* - View diffs: `./gradlew :composeApp:compareRoborazziDebug`
* One parameterized test per surface gives granular CI failures — 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).
*/
@RunWith(RobolectricTestRunner::class)
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h800dp-mdpi")
class ScreenshotTests {
class ScreenshotTests(
private val surface: GallerySurface,
) {
// ---------- Login screen showcase ----------
@Test
fun loginScreen_default_light() = runScreen("login_default_light", AppThemes.Default, darkTheme = false) {
LoginShowcase()
/**
* Compose Multiplatform's `stringResource()` loads text via a
* JVM-static context held by `AndroidContextProvider`. 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.
*
* 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!".
*/
@Before
fun bootstrapComposeResources() {
val appContext = ApplicationProvider.getApplicationContext<android.content.Context>()
val providerClass = Class.forName("org.jetbrains.compose.resources.AndroidContextProvider")
val companionField = providerClass.getDeclaredField("Companion").apply { isAccessible = true }
val companion = companionField.get(null)
val setter = companion.javaClass.getMethod("setANDROID_CONTEXT", android.content.Context::class.java)
setter.invoke(companion, appContext)
}
@Test
fun loginScreen_default_dark() = runScreen("login_default_dark", AppThemes.Default, darkTheme = true) {
LoginShowcase()
}
@Test
fun loginScreen_ocean_light() = runScreen("login_ocean_light", AppThemes.Ocean, darkTheme = false) {
LoginShowcase()
}
@Test
fun loginScreen_ocean_dark() = runScreen("login_ocean_dark", AppThemes.Ocean, darkTheme = true) {
LoginShowcase()
}
@Test
fun loginScreen_midnight_light() = runScreen("login_midnight_light", AppThemes.Midnight, darkTheme = false) {
LoginShowcase()
}
@Test
fun loginScreen_midnight_dark() = runScreen("login_midnight_dark", AppThemes.Midnight, darkTheme = true) {
LoginShowcase()
}
// ---------- Tasks list showcase ----------
@Test
fun tasksScreen_default_light() = runScreen("tasks_default_light", AppThemes.Default, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_default_dark() = runScreen("tasks_default_dark", AppThemes.Default, darkTheme = true) {
TasksShowcase()
}
@Test
fun tasksScreen_ocean_light() = runScreen("tasks_ocean_light", AppThemes.Ocean, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_ocean_dark() = runScreen("tasks_ocean_dark", AppThemes.Ocean, darkTheme = true) {
TasksShowcase()
}
@Test
fun tasksScreen_midnight_light() = runScreen("tasks_midnight_light", AppThemes.Midnight, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_midnight_dark() = runScreen("tasks_midnight_dark", AppThemes.Midnight, darkTheme = true) {
TasksShowcase()
}
// ---------- Residences list showcase ----------
@Test
fun residencesScreen_default_light() = runScreen("residences_default_light", AppThemes.Default, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_default_dark() = runScreen("residences_default_dark", AppThemes.Default, darkTheme = true) {
ResidencesShowcase()
}
@Test
fun residencesScreen_ocean_light() = runScreen("residences_ocean_light", AppThemes.Ocean, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_ocean_dark() = runScreen("residences_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ResidencesShowcase()
}
@Test
fun residencesScreen_midnight_light() = runScreen("residences_midnight_light", AppThemes.Midnight, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_midnight_dark() = runScreen("residences_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ResidencesShowcase()
}
// ---------- Profile/theme-selection / complete-task showcases ----------
@Test
fun profileScreen_default_light() = runScreen("profile_default_light", AppThemes.Default, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_default_dark() = runScreen("profile_default_dark", AppThemes.Default, darkTheme = true) {
ProfileShowcase()
}
@Test
fun profileScreen_ocean_light() = runScreen("profile_ocean_light", AppThemes.Ocean, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_ocean_dark() = runScreen("profile_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ProfileShowcase()
}
@Test
fun profileScreen_midnight_light() = runScreen("profile_midnight_light", AppThemes.Midnight, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_midnight_dark() = runScreen("profile_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ProfileShowcase()
}
@Test
fun themeSelection_default_light() = runScreen("themes_default_light", AppThemes.Default, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_default_dark() = runScreen("themes_default_dark", AppThemes.Default, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_ocean_light() = runScreen("themes_ocean_light", AppThemes.Ocean, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_ocean_dark() = runScreen("themes_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_midnight_light() = runScreen("themes_midnight_light", AppThemes.Midnight, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_midnight_dark() = runScreen("themes_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun completeTask_default_light() = runScreen("complete_task_default_light", AppThemes.Default, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_default_dark() = runScreen("complete_task_default_dark", AppThemes.Default, darkTheme = true) {
CompleteTaskShowcase()
}
@Test
fun completeTask_ocean_light() = runScreen("complete_task_ocean_light", AppThemes.Ocean, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_ocean_dark() = runScreen("complete_task_ocean_dark", AppThemes.Ocean, darkTheme = true) {
CompleteTaskShowcase()
}
@Test
fun completeTask_midnight_light() = runScreen("complete_task_midnight_light", AppThemes.Midnight, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_midnight_dark() = runScreen("complete_task_midnight_dark", AppThemes.Midnight, darkTheme = true) {
CompleteTaskShowcase()
}
// ---------- Shared runner ----------
private fun runScreen(
name: String,
theme: ThemeColors,
darkTheme: Boolean,
content: @Composable () -> Unit,
) {
captureRoboImage(
filePath = "build/outputs/roborazzi/$name.png",
roborazziOptions = RoborazziOptions(),
) {
HoneyDueTheme(darkTheme = darkTheme, themeColors = theme) {
content()
fun captureAllVariants() {
Variant.all().forEach { variant ->
val fileName = "${surface.name}_${variant.state}_${variant.mode}.png"
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
HoneyDueTheme(
themeColors = AppThemes.Default,
darkTheme = variant.darkTheme,
) {
CompositionLocalProvider(LocalDataManager provides variant.dataManager()) {
Box(Modifier.fillMaxSize()) {
surface.content()
}
}
}
}
}
}
}
// ============ Theme-agnostic showcase composables ============
//
// Each mirrors the *surface* (not the full data pipeline) of its named
// production screen. This keeps Roborazzi tests hermetic — no Ktor
// client, no DataManager, no ViewModel — while still exercising every
// colour slot in the MaterialTheme that ships with the app.
@Composable
private fun LoginShowcase() {
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
"honeyDue",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Keep your home running",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(value = "testuser", onValueChange = {}, label = { Text("Username") })
OutlinedTextField(value = "•••••••••", onValueChange = {}, label = { Text("Password") })
Button(onClick = {}, modifier = Modifier.fillMaxSize(1f)) {
Text("Sign In")
}
TextButton(onClick = {}) { Text("Forgot password?") }
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun surfaces(): List<Array<Any>> =
gallerySurfaces.map { arrayOf<Any>(it) }
}
}
@Composable
private fun TasksShowcase() {
Scaffold(topBar = {
TopAppBar(
title = { Text("Tasks") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
/**
* 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).
*/
private data class Variant(
val state: String,
val mode: 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() },
)
}) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
listOf("Replace HVAC filter", "Test smoke alarms", "Clean gutters").forEach { title ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Task, null, tint = MaterialTheme.colorScheme.primary)
Text(title, style = MaterialTheme.typography.bodyLarge)
}
}
}
Button(onClick = {}, colors = ButtonDefaults.buttonColors()) {
Icon(Icons.Filled.Add, null)
Text("New task", modifier = Modifier.padding(start = 8.dp))
}
}
}
}
@Composable
private fun ResidencesShowcase() {
Scaffold(topBar = {
TopAppBar(title = { Text("Residences") })
}) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Icons.Filled.Home, null, tint = MaterialTheme.colorScheme.primary)
Text("Primary Home", style = MaterialTheme.typography.titleMedium)
}
Text(
"1234 Sunflower Lane",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedButton(onClick = {}) { Text("Add residence") }
}
}
}
@Composable
private fun ProfileShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Profile") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"testuser",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"claude@treymail.com",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
listOf("Notifications", "Theme", "Help").forEach { label ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Text(label, modifier = Modifier.padding(16.dp))
}
}
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) { Text("Log out") }
}
}
}
@Composable
private fun ThemePaletteShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Theme") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
"Primary" to MaterialTheme.colorScheme.primary,
"Secondary" to MaterialTheme.colorScheme.secondary,
"Tertiary" to MaterialTheme.colorScheme.tertiary,
"Surface" to MaterialTheme.colorScheme.surface,
"Error" to MaterialTheme.colorScheme.error,
).forEach { (label, color) ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Card(
colors = CardDefaults.cardColors(containerColor = color),
shape = RoundedCornerShape(8.dp),
) {
Column(Modifier.padding(24.dp)) { Text(" ", color = Color.Transparent) }
}
Text(label, color = MaterialTheme.colorScheme.onBackground)
}
}
}
}
}
@Composable
private fun CompleteTaskShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Complete Task") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Test smoke alarms", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(value = "42.50", onValueChange = {}, label = { Text("Actual cost") })
OutlinedTextField(value = "All alarms passed.", onValueChange = {}, label = { Text("Notes") })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {}) { Text("Cancel") }
Button(onClick = {}) { Text("Mark complete") }
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

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