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>
@@ -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 = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 44 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/home_empty_dark.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/home_empty_light.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/home_populated_dark.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/login_empty_dark.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/login_empty_light.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/register_empty_dark.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |