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>
@@ -223,11 +223,21 @@ compose.desktop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roborazzi screenshot-regression plugin (P8). Pin the golden-image
|
// Roborazzi screenshot-regression plugin (parity gallery, P2). Pin the
|
||||||
// output directory inside the test source set so goldens live in git
|
// golden-image output directory inside the test source set so goldens live
|
||||||
// alongside the tests themselves. Anything under build/ is gitignored
|
// in git alongside the tests themselves. Anything under build/ is
|
||||||
// and gets blown away by `gradle clean` — not where committed goldens
|
// gitignored and gets blown away by `gradle clean` — not where committed
|
||||||
// belong.
|
// 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 {
|
roborazzi {
|
||||||
outputDir.set(layout.projectDirectory.dir("src/androidUnitTest/roborazzi"))
|
outputDir.set(layout.projectDirectory.dir("src/androidUnitTest/roborazzi"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||||
package com.tt.honeyDue.screenshot
|
package com.tt.honeyDue.screenshot
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import com.github.takahirom.roborazzi.RoborazziOptions
|
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
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.AppThemes
|
||||||
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
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.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
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
|
* For every entry in [gallerySurfaces] we capture four variants:
|
||||||
* required. The goal is to catch accidental UI drift (colour, spacing,
|
* empty × light, empty × dark, populated × light, populated × dark
|
||||||
* typography) on PRs by diffing generated PNGs against a committed
|
|
||||||
* golden set.
|
|
||||||
*
|
*
|
||||||
* Matrix: 6 surfaces × 3 themes (Default / Ocean / Midnight) × 2 modes
|
* Per surface that's 4 PNGs × ~40 surfaces ≈ 160 goldens. Paired with the
|
||||||
* (light / dark) = 36 images. This is a conservative baseline; the full
|
* iOS swift-snapshot-testing gallery (P3) that captures the same set of
|
||||||
* 11-theme matrix would produce 132+ images and is deferred.
|
* (screen, data, theme) tuples, any visual divergence between the two
|
||||||
|
* platforms surfaces here as a golden diff rather than silently shipping.
|
||||||
*
|
*
|
||||||
* Implementation notes:
|
* How this differs from the showcase tests that lived here before:
|
||||||
* - We use the top-level `captureRoboImage(path) { composable }` form
|
* - Showcases rendered hand-crafted theme-agnostic surfaces; now we
|
||||||
* from roborazzi-compose. That helper registers
|
* render the actual production composables (`LoginScreen(…)`, etc.)
|
||||||
* `RoborazziTransparentActivity` at runtime via Robolectric's shadow
|
* through the fixture-backed [LocalDataManager].
|
||||||
* PackageManager, so we don't need `createComposeRule()` /
|
* - Surfaces are declared in [GallerySurfaces.kt] instead of being
|
||||||
* `ActivityScenarioRule<ComponentActivity>` and therefore avoid the
|
* inlined, so adding a new screen is a one-line change.
|
||||||
* "Unable to resolve activity for Intent MAIN/LAUNCHER cmp=.../ComponentActivity"
|
* - Previously 6 surfaces × 3 themes × 2 modes; now the matrix is
|
||||||
* failure that bit the initial scaffolding (RoboMonitoringInstrumentation:102).
|
* N surfaces × {empty, populated} × {light, dark} — themes beyond
|
||||||
* - Goldens land under `composeApp/build/outputs/roborazzi/`, which the
|
* the default are intentionally out of scope (theme variation is
|
||||||
* Roborazzi Gradle plugin picks up for record / verify / compare.
|
* covered by the dedicated theme_selection surface).
|
||||||
*
|
*
|
||||||
* Workflow:
|
* One parameterized test per surface gives granular CI failures — the
|
||||||
* - Initial record: `./gradlew :composeApp:recordRoborazziDebug`
|
* report shows `ScreenshotTests[login]`, `ScreenshotTests[tasks]`, etc.
|
||||||
* - Verify in CI: `./gradlew :composeApp:verifyRoborazziDebug`
|
* rather than one monolithic failure when any surface drifts.
|
||||||
* - View diffs: `./gradlew :composeApp:compareRoborazziDebug`
|
*
|
||||||
|
* 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)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@Config(qualifiers = "w360dp-h800dp-mdpi")
|
@Config(qualifiers = "w360dp-h800dp-mdpi")
|
||||||
class ScreenshotTests {
|
class ScreenshotTests(
|
||||||
|
private val surface: GallerySurface,
|
||||||
|
) {
|
||||||
|
|
||||||
// ---------- Login screen showcase ----------
|
/**
|
||||||
|
* Compose Multiplatform's `stringResource()` loads text via a
|
||||||
@Test
|
* JVM-static context held by `AndroidContextProvider`. In a real APK
|
||||||
fun loginScreen_default_light() = runScreen("login_default_light", AppThemes.Default, darkTheme = false) {
|
* that ContentProvider is registered in the manifest and populated at
|
||||||
LoginShowcase()
|
* 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
|
@Test
|
||||||
fun loginScreen_default_dark() = runScreen("login_default_dark", AppThemes.Default, darkTheme = true) {
|
fun captureAllVariants() {
|
||||||
LoginShowcase()
|
Variant.all().forEach { variant ->
|
||||||
}
|
val fileName = "${surface.name}_${variant.state}_${variant.mode}.png"
|
||||||
|
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
|
||||||
@Test
|
HoneyDueTheme(
|
||||||
fun loginScreen_ocean_light() = runScreen("login_ocean_light", AppThemes.Ocean, darkTheme = false) {
|
themeColors = AppThemes.Default,
|
||||||
LoginShowcase()
|
darkTheme = variant.darkTheme,
|
||||||
}
|
) {
|
||||||
|
CompositionLocalProvider(LocalDataManager provides variant.dataManager()) {
|
||||||
@Test
|
Box(Modifier.fillMaxSize()) {
|
||||||
fun loginScreen_ocean_dark() = runScreen("login_ocean_dark", AppThemes.Ocean, darkTheme = true) {
|
surface.content()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Theme-agnostic showcase composables ============
|
companion object {
|
||||||
//
|
@JvmStatic
|
||||||
// Each mirrors the *surface* (not the full data pipeline) of its named
|
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||||
// production screen. This keeps Roborazzi tests hermetic — no Ktor
|
fun surfaces(): List<Array<Any>> =
|
||||||
// client, no DataManager, no ViewModel — while still exercising every
|
gallerySurfaces.map { arrayOf<Any>(it) }
|
||||||
// colour slot in the MaterialTheme that ships with the app.
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LoginShowcase() {
|
|
||||||
Scaffold { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"honeyDue",
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Keep your home running",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
OutlinedTextField(value = "testuser", onValueChange = {}, label = { Text("Username") })
|
|
||||||
OutlinedTextField(value = "•••••••••", onValueChange = {}, label = { Text("Password") })
|
|
||||||
Button(onClick = {}, modifier = Modifier.fillMaxSize(1f)) {
|
|
||||||
Text("Sign In")
|
|
||||||
}
|
|
||||||
TextButton(onClick = {}) { Text("Forgot password?") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
/**
|
||||||
private fun TasksShowcase() {
|
* One of the four render-variants captured per surface. The
|
||||||
Scaffold(topBar = {
|
* `dataManager` factory is invoked lazily so each capture gets its own
|
||||||
TopAppBar(
|
* pristine fixture (avoiding cross-test StateFlow mutation).
|
||||||
title = { Text("Tasks") },
|
*/
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
private data class Variant(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
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 |