Compare commits
12 Commits
031d61157f
...
rc/android
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
170a6d0e40 | ||
|
|
16096f4b70 | ||
|
|
9fa58352c0 | ||
|
|
316b1f709d | ||
|
|
42ccbdcbd6 | ||
|
|
f0f8dfb68b | ||
|
|
2230cde071 | ||
|
|
f83e89bee3 | ||
|
|
ab0e5c450c | ||
|
|
b24469bf38 | ||
|
|
6c3c9d3e0c | ||
|
|
3944223a5e |
@@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -58,6 +59,15 @@ actual fun rememberImagePicker(
|
|||||||
actual fun rememberCameraPicker(
|
actual fun rememberCameraPicker(
|
||||||
onImageCaptured: (ImageData) -> Unit
|
onImageCaptured: (ImageData) -> Unit
|
||||||
): () -> Unit {
|
): () -> Unit {
|
||||||
|
// Compose previews and Roborazzi snapshot tests run without a
|
||||||
|
// `FileProvider` resolvable cache path — `getUriForFile` throws
|
||||||
|
// `Failed to find configured root...` because the test cache dir
|
||||||
|
// isn't registered in the manifest's `file_paths.xml`. The launch
|
||||||
|
// callback is never invoked in a preview/snapshot anyway, so
|
||||||
|
// returning a no-op keeps the composition clean.
|
||||||
|
if (LocalInspectionMode.current) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
// Create a temp file URI for the camera to save to
|
// Create a temp file URI for the camera to save to
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.tt.honeyDue.architecture
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture regression gate.
|
||||||
|
*
|
||||||
|
* Scans `composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/` and
|
||||||
|
* asserts every ViewModel either:
|
||||||
|
* a) accepts `dataManager: IDataManager` as a constructor parameter, or
|
||||||
|
* b) is explicitly allowlisted in [WORKFLOW_ONLY_VMS] as a
|
||||||
|
* workflow/mutation-only VM.
|
||||||
|
*
|
||||||
|
* Prevents the Dec 3 2025 regression (4 VMs holding independent
|
||||||
|
* `MutableStateFlow` read-state instead of deriving from DataManager).
|
||||||
|
* See `docs/parity-gallery.md` "Known limitations" for the history.
|
||||||
|
*
|
||||||
|
* Workflow / write-only (no read-state to mirror):
|
||||||
|
* * `TaskCompletionViewModel` — single-shot create mutation
|
||||||
|
* * `OnboardingViewModel` — wizard form + per-step ApiResult
|
||||||
|
* * `PasswordResetViewModel` — wizard form + per-step ApiResult
|
||||||
|
*
|
||||||
|
* Everyone else must accept the `dataManager` ctor param.
|
||||||
|
*/
|
||||||
|
class NoIndependentViewModelStateFileScanTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun every_read_state_vm_accepts_iDataManager_ctor_param() {
|
||||||
|
val vmSources = findViewModelSources()
|
||||||
|
val violations = mutableListOf<String>()
|
||||||
|
|
||||||
|
vmSources.forEach { file ->
|
||||||
|
val name = file.name
|
||||||
|
if (name in WORKFLOW_ONLY_VMS) return@forEach
|
||||||
|
|
||||||
|
val body = file.readText()
|
||||||
|
val hasCtorParam = body.contains(Regex("""dataManager:\s*IDataManager"""))
|
||||||
|
if (!hasCtorParam) {
|
||||||
|
violations.add(
|
||||||
|
"$name — expected `dataManager: IDataManager = DataManager` " +
|
||||||
|
"constructor parameter. Without this, read-state can't derive " +
|
||||||
|
"from the DataManager single source of truth and snapshot " +
|
||||||
|
"tests can't substitute a fixture. " +
|
||||||
|
"If this VM genuinely has no read-state (workflow / mutation only), " +
|
||||||
|
"add its filename to WORKFLOW_ONLY_VMS with justification.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.isNotEmpty()) {
|
||||||
|
fail(
|
||||||
|
"ViewModel architecture regression (see docs/parity-gallery.md):\n" +
|
||||||
|
violations.joinToString(separator = "\n") { " - $it" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun read_state_flows_should_be_derived_not_independent() {
|
||||||
|
val vmSources = findViewModelSources()
|
||||||
|
val violations = mutableListOf<String>()
|
||||||
|
|
||||||
|
// Names of fields that track one-shot mutation/workflow feedback —
|
||||||
|
// exempt from the "must be derived" rule.
|
||||||
|
val mutationFieldPrefixes = listOf(
|
||||||
|
"create", "update", "delete", "toggle", "download",
|
||||||
|
"upload", "archive", "unarchive", "cancel", "uncancel",
|
||||||
|
"mark", "generate", "request", "submit", "login", "register",
|
||||||
|
"reset", "forgot", "verify", "apple", "google",
|
||||||
|
"join", "addNew", "addTask", "taskAddNew",
|
||||||
|
"action", "currentStep", "resetToken", "email", "selected",
|
||||||
|
// Local-only state not cached by DataManager:
|
||||||
|
"category", // NotificationPreferencesViewModel per-channel local toggles
|
||||||
|
)
|
||||||
|
|
||||||
|
vmSources.forEach { file ->
|
||||||
|
val name = file.name
|
||||||
|
if (name in WORKFLOW_ONLY_VMS) return@forEach
|
||||||
|
if (name == "AuthViewModel.kt") return@forEach // 11 one-shot states, all mutation-feedback; allowlisted as a file
|
||||||
|
|
||||||
|
val body = file.readText()
|
||||||
|
val mutableReads = Regex("""private val (_[a-zA-Z]+State)\s*=\s*MutableStateFlow""")
|
||||||
|
.findAll(body).map { it.groupValues[1] }.toList()
|
||||||
|
|
||||||
|
mutableReads.forEach { fieldName ->
|
||||||
|
val root = fieldName.removePrefix("_").removeSuffix("State")
|
||||||
|
val isMutationFeedback = mutationFieldPrefixes.any {
|
||||||
|
root.lowercase().startsWith(it.lowercase())
|
||||||
|
}
|
||||||
|
if (!isMutationFeedback) {
|
||||||
|
violations.add(
|
||||||
|
"$name — field `$fieldName` looks like cached read-state " +
|
||||||
|
"(not matching any mutation-feedback prefix). Derive it from " +
|
||||||
|
"DataManager via `dataManager.xxx.map { ... }.stateIn(...)` " +
|
||||||
|
"instead of owning a MutableStateFlow. If this field really " +
|
||||||
|
"is mutation feedback, add its name prefix to " +
|
||||||
|
"mutationFieldPrefixes in this test.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.isNotEmpty()) {
|
||||||
|
fail(
|
||||||
|
"ViewModel state-ownership regression (see docs/parity-gallery.md):\n" +
|
||||||
|
violations.joinToString(separator = "\n") { " - $it" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findViewModelSources(): List<File> {
|
||||||
|
// Test cwd is `composeApp/` — resolve from the project dir.
|
||||||
|
val vmDir = File("src/commonMain/kotlin/com/tt/honeyDue/viewmodel")
|
||||||
|
check(vmDir.exists()) {
|
||||||
|
"expected VM source directory not found: ${vmDir.absolutePath} (cwd=${File(".").absolutePath})"
|
||||||
|
}
|
||||||
|
return vmDir.listFiles { f -> f.extension == "kt" }?.toList().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** VMs that legitimately don't need DataManager injection. */
|
||||||
|
val WORKFLOW_ONLY_VMS: Set<String> = setOf(
|
||||||
|
"TaskCompletionViewModel.kt",
|
||||||
|
"OnboardingViewModel.kt",
|
||||||
|
"PasswordResetViewModel.kt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.tt.honeyDue.screenshot
|
||||||
|
|
||||||
|
import com.tt.honeyDue.testing.GalleryScreens
|
||||||
|
import com.tt.honeyDue.testing.Platform
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parity gate — asserts [gallerySurfaces] is exactly the set of screens
|
||||||
|
* declared in [GalleryScreens] with [Platform.ANDROID] in their platforms.
|
||||||
|
*
|
||||||
|
* If this fails, either:
|
||||||
|
* - A new screen was added to [gallerySurfaces] but missing from the
|
||||||
|
* canonical manifest — update [GalleryScreens.all].
|
||||||
|
* - A new screen was added to the manifest but not wired into
|
||||||
|
* [gallerySurfaces] — add the corresponding `GallerySurface(...)`
|
||||||
|
* entry.
|
||||||
|
* - A rename landed on only one side — reconcile.
|
||||||
|
*
|
||||||
|
* This keeps Android and iOS from silently drifting apart in coverage.
|
||||||
|
* The iOS equivalent (`GalleryManifestParityTest.swift`) enforces the
|
||||||
|
* same invariant on the Swift test file.
|
||||||
|
*/
|
||||||
|
class GalleryManifestParityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun android_surfaces_match_manifest_exactly() {
|
||||||
|
val actual = gallerySurfaces.map { it.name }.toSet()
|
||||||
|
val expected = GalleryScreens.forAndroid.keys
|
||||||
|
|
||||||
|
val missing = expected - actual
|
||||||
|
val extra = actual - expected
|
||||||
|
|
||||||
|
if (missing.isNotEmpty() || extra.isNotEmpty()) {
|
||||||
|
val message = buildString {
|
||||||
|
appendLine("Android GallerySurfaces drifted from canonical manifest.")
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
appendLine()
|
||||||
|
appendLine("Screens in manifest but missing from GallerySurfaces.kt:")
|
||||||
|
missing.sorted().forEach { appendLine(" - $it") }
|
||||||
|
}
|
||||||
|
if (extra.isNotEmpty()) {
|
||||||
|
appendLine()
|
||||||
|
appendLine("Screens in GallerySurfaces.kt but missing from manifest:")
|
||||||
|
extra.sorted().forEach { appendLine(" - $it") }
|
||||||
|
}
|
||||||
|
appendLine()
|
||||||
|
appendLine("Reconcile by editing com.tt.honeyDue.testing.GalleryScreens and/or")
|
||||||
|
appendLine("com.tt.honeyDue.screenshot.GallerySurfaces so both agree.")
|
||||||
|
}
|
||||||
|
kotlin.test.fail(message)
|
||||||
|
}
|
||||||
|
assertEquals(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun no_duplicate_surface_names() {
|
||||||
|
val duplicates = gallerySurfaces.map { it.name }
|
||||||
|
.groupingBy { it }
|
||||||
|
.eachCount()
|
||||||
|
.filterValues { it > 1 }
|
||||||
|
assertTrue(
|
||||||
|
duplicates.isEmpty(),
|
||||||
|
"Duplicate surface names in GallerySurfaces.kt: $duplicates",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
package com.tt.honeyDue.screenshot
|
package com.tt.honeyDue.screenshot
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import com.tt.honeyDue.testing.Fixtures
|
import com.tt.honeyDue.testing.Fixtures
|
||||||
import com.tt.honeyDue.ui.screens.AddDocumentScreen
|
import com.tt.honeyDue.ui.screens.AddDocumentScreen
|
||||||
import com.tt.honeyDue.ui.screens.AddResidenceScreen
|
import com.tt.honeyDue.ui.screens.AddResidenceScreen
|
||||||
@@ -25,11 +26,10 @@ import com.tt.honeyDue.ui.screens.RegisterScreen
|
|||||||
import com.tt.honeyDue.ui.screens.ResetPasswordScreen
|
import com.tt.honeyDue.ui.screens.ResetPasswordScreen
|
||||||
import com.tt.honeyDue.ui.screens.ResidenceDetailScreen
|
import com.tt.honeyDue.ui.screens.ResidenceDetailScreen
|
||||||
import com.tt.honeyDue.ui.screens.ResidencesScreen
|
import com.tt.honeyDue.ui.screens.ResidencesScreen
|
||||||
import com.tt.honeyDue.ui.screens.TasksScreen
|
|
||||||
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
|
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
|
||||||
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
|
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
|
||||||
import com.tt.honeyDue.ui.screens.dev.AnimationTestingScreen
|
|
||||||
import com.tt.honeyDue.ui.screens.onboarding.OnboardingCreateAccountContent
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingCreateAccountContent
|
||||||
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingFirstTaskContent
|
||||||
import com.tt.honeyDue.ui.screens.onboarding.OnboardingHomeProfileContent
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingHomeProfileContent
|
||||||
import com.tt.honeyDue.ui.screens.onboarding.OnboardingJoinResidenceContent
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingJoinResidenceContent
|
||||||
import com.tt.honeyDue.ui.screens.onboarding.OnboardingLocationContent
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingLocationContent
|
||||||
@@ -40,34 +40,44 @@ import com.tt.honeyDue.ui.screens.onboarding.OnboardingVerifyEmailContent
|
|||||||
import com.tt.honeyDue.ui.screens.onboarding.OnboardingWelcomeContent
|
import com.tt.honeyDue.ui.screens.onboarding.OnboardingWelcomeContent
|
||||||
import com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen
|
import com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen
|
||||||
import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen
|
import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen
|
||||||
|
import com.tt.honeyDue.ui.screens.task.AddTaskWithResidenceScreen
|
||||||
import com.tt.honeyDue.ui.screens.task.TaskSuggestionsScreen
|
import com.tt.honeyDue.ui.screens.task.TaskSuggestionsScreen
|
||||||
import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen
|
import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen
|
||||||
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
|
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
|
||||||
|
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
||||||
|
import com.tt.honeyDue.viewmodel.DocumentViewModel
|
||||||
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
||||||
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
|
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declarative manifest of every primary screen in the app that the parity
|
* Declarative manifest of every Android gallery surface. Must stay in sync
|
||||||
* gallery captures. Each entry renders the production composable directly —
|
* with the canonical [com.tt.honeyDue.testing.GalleryScreens] manifest —
|
||||||
* the screen reads its data from [com.tt.honeyDue.data.LocalDataManager],
|
* [GalleryManifestParityTest] fails CI if the two drift.
|
||||||
* which the capture driver overrides with a [com.tt.honeyDue.testing.FixtureDataManager]
|
|
||||||
* (empty or populated) per variant.
|
|
||||||
*
|
*
|
||||||
* Scope: the screens users land on. We deliberately skip:
|
* Scope: the screens users land on. We deliberately skip:
|
||||||
* - dialogs that live inside a host screen (already captured on the host),
|
* - dialogs that live inside a host screen (already captured on the host),
|
||||||
* - animation sub-views / decorative components in AnimationTesting/,
|
* - animation sub-views / decorative components in AnimationTesting/,
|
||||||
* - widget views (Android Glance / iOS WidgetKit — separate surface),
|
* - widget views (Android Glance / iOS WidgetKit — separate surface),
|
||||||
* - shared helper composables listed under `category: shared` in
|
* - shared helper composables (loaders, error rows, thumbnails — they
|
||||||
* docs/ios-parity/screens.json (loaders, error rows, thumbnails — they
|
|
||||||
* only appear as part of a parent screen).
|
* only appear as part of a parent screen).
|
||||||
*
|
*
|
||||||
* Screens that require a construction-time ViewModel (`OnboardingViewModel`,
|
* Detail-VM pattern (contractor_detail, document_detail, edit_document):
|
||||||
* `PasswordResetViewModel`) instantiate it inline here. The production code
|
* the VM is created with the fixture id already pre-selected, so
|
||||||
* paths start the viewmodel's own `launch { APILayer.xxx() }` on first
|
* `stateIn(SharingStarted.Eagerly, initialValue = dataManager.x[id])`
|
||||||
* composition — those calls fail fast in the hermetic Robolectric
|
* emits `Success(entity)` on first composition. Without this pre-select,
|
||||||
* environment, but the composition itself renders the surface from the
|
* the screens' own `LaunchedEffect(id) { vm.loadX(id) }` dispatches the id
|
||||||
* injected [com.tt.honeyDue.data.LocalDataManager] before any network
|
* assignment to a coroutine that runs *after* Roborazzi captures the
|
||||||
* result arrives, which is exactly what we want to compare against iOS.
|
* frame — so both empty and populated captures would render the `Idle`
|
||||||
|
* state and be byte-identical.
|
||||||
|
*
|
||||||
|
* Screens that require a construction-time ViewModel
|
||||||
|
* ([OnboardingViewModel], [PasswordResetViewModel]) instantiate it inline
|
||||||
|
* here. The production code paths start the viewmodel's own
|
||||||
|
* `launch { APILayer.xxx() }` on first composition — those calls fail fast
|
||||||
|
* in the hermetic Robolectric environment, but the composition itself
|
||||||
|
* renders the surface from the injected
|
||||||
|
* [com.tt.honeyDue.data.LocalDataManager] before any network result
|
||||||
|
* arrives, which is exactly what we want to compare against iOS.
|
||||||
*/
|
*/
|
||||||
data class GallerySurface(
|
data class GallerySurface(
|
||||||
/** Snake-case identifier; used as the golden file-name prefix. */
|
/** Snake-case identifier; used as the golden file-name prefix. */
|
||||||
@@ -177,6 +187,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
|
|||||||
onJoined = {},
|
onJoined = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
GallerySurface("onboarding_first_task") {
|
||||||
|
OnboardingFirstTaskContent(
|
||||||
|
viewModel = OnboardingViewModel(),
|
||||||
|
onTasksAdded = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
GallerySurface("onboarding_subscription") {
|
GallerySurface("onboarding_subscription") {
|
||||||
OnboardingSubscriptionContent(
|
OnboardingSubscriptionContent(
|
||||||
onSubscribe = {},
|
onSubscribe = {},
|
||||||
@@ -184,7 +200,7 @@ val gallerySurfaces: List<GallerySurface> = listOf(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Home / main navigation ----------
|
// ---------- Home (Android-only dashboard) ----------
|
||||||
GallerySurface("home") {
|
GallerySurface("home") {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
onNavigateToResidences = {},
|
onNavigateToResidences = {},
|
||||||
@@ -240,12 +256,16 @@ val gallerySurfaces: List<GallerySurface> = listOf(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ---------- Tasks ----------
|
// ---------- Tasks ----------
|
||||||
GallerySurface("tasks") {
|
|
||||||
TasksScreen(onNavigateBack = {})
|
|
||||||
},
|
|
||||||
GallerySurface("all_tasks") {
|
GallerySurface("all_tasks") {
|
||||||
AllTasksScreen(onNavigateToEditTask = {})
|
AllTasksScreen(onNavigateToEditTask = {})
|
||||||
},
|
},
|
||||||
|
GallerySurface("add_task_with_residence") {
|
||||||
|
AddTaskWithResidenceScreen(
|
||||||
|
residenceId = Fixtures.primaryHome.id,
|
||||||
|
onNavigateBack = {},
|
||||||
|
onCreated = {},
|
||||||
|
)
|
||||||
|
},
|
||||||
GallerySurface("edit_task") {
|
GallerySurface("edit_task") {
|
||||||
EditTaskScreen(
|
EditTaskScreen(
|
||||||
task = Fixtures.tasks.first(),
|
task = Fixtures.tasks.first(),
|
||||||
@@ -285,9 +305,20 @@ val gallerySurfaces: List<GallerySurface> = listOf(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
GallerySurface("contractor_detail") {
|
GallerySurface("contractor_detail") {
|
||||||
|
val id = Fixtures.contractors.first().id
|
||||||
|
// Pass `initialSelectedContractorId` at VM construction so the
|
||||||
|
// synchronous `stateIn` initial-value closure observes both the
|
||||||
|
// id AND the fixture-seeded `dataManager.contractorDetail[id]`,
|
||||||
|
// emitting `Success(contractor)` on first composition. Without
|
||||||
|
// this the screen's own `LaunchedEffect(id) { vm.loadContractorDetail(id) }`
|
||||||
|
// dispatches the id assignment to a coroutine that runs after
|
||||||
|
// the frame is captured, leaving both empty and populated
|
||||||
|
// captures byte-identical on the `Idle` branch.
|
||||||
|
val vm = remember { ContractorViewModel(initialSelectedContractorId = id) }
|
||||||
ContractorDetailScreen(
|
ContractorDetailScreen(
|
||||||
contractorId = Fixtures.contractors.first().id,
|
contractorId = id,
|
||||||
onNavigateBack = {},
|
onNavigateBack = {},
|
||||||
|
viewModel = vm,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -299,10 +330,13 @@ val gallerySurfaces: List<GallerySurface> = listOf(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
GallerySurface("document_detail") {
|
GallerySurface("document_detail") {
|
||||||
|
val id = Fixtures.documents.first().id ?: 0
|
||||||
|
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
|
||||||
DocumentDetailScreen(
|
DocumentDetailScreen(
|
||||||
documentId = Fixtures.documents.first().id ?: 0,
|
documentId = id,
|
||||||
onNavigateBack = {},
|
onNavigateBack = {},
|
||||||
onNavigateToEdit = {},
|
onNavigateToEdit = { _ -> },
|
||||||
|
documentViewModel = vm,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
GallerySurface("add_document") {
|
GallerySurface("add_document") {
|
||||||
@@ -313,9 +347,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
GallerySurface("edit_document") {
|
GallerySurface("edit_document") {
|
||||||
|
val id = Fixtures.documents.first().id ?: 0
|
||||||
|
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
|
||||||
EditDocumentScreen(
|
EditDocumentScreen(
|
||||||
documentId = Fixtures.documents.first().id ?: 0,
|
documentId = id,
|
||||||
onNavigateBack = {},
|
onNavigateBack = {},
|
||||||
|
documentViewModel = vm,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -326,14 +363,11 @@ val gallerySurfaces: List<GallerySurface> = listOf(
|
|||||||
onLogout = {},
|
onLogout = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
GallerySurface("theme_selection") {
|
|
||||||
ThemeSelectionScreen(onNavigateBack = {})
|
|
||||||
},
|
|
||||||
GallerySurface("notification_preferences") {
|
GallerySurface("notification_preferences") {
|
||||||
NotificationPreferencesScreen(onNavigateBack = {})
|
NotificationPreferencesScreen(onNavigateBack = {})
|
||||||
},
|
},
|
||||||
GallerySurface("animation_testing") {
|
GallerySurface("theme_selection") {
|
||||||
AnimationTestingScreen(onNavigateBack = {})
|
ThemeSelectionScreen(onNavigateBack = {})
|
||||||
},
|
},
|
||||||
GallerySurface("biometric_lock") {
|
GallerySurface("biometric_lock") {
|
||||||
BiometricLockScreen(onUnlocked = {})
|
BiometricLockScreen(onUnlocked = {})
|
||||||
|
|||||||
@@ -6,54 +6,60 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
import com.tt.honeyDue.data.IDataManager
|
import com.tt.honeyDue.data.IDataManager
|
||||||
import com.tt.honeyDue.data.LocalDataManager
|
import com.tt.honeyDue.data.LocalDataManager
|
||||||
import com.tt.honeyDue.testing.FixtureDataManager
|
import com.tt.honeyDue.testing.FixtureDataManager
|
||||||
|
import com.tt.honeyDue.testing.GalleryCategory
|
||||||
|
import com.tt.honeyDue.testing.GalleryScreens
|
||||||
import com.tt.honeyDue.ui.theme.AppThemes
|
import com.tt.honeyDue.ui.theme.AppThemes
|
||||||
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parity-gallery Roborazzi snapshot tests (P2).
|
* Parity-gallery Roborazzi snapshot tests.
|
||||||
*
|
*
|
||||||
* For every entry in [gallerySurfaces] we capture four variants:
|
* Variant matrix (driven by [GalleryCategory] in the canonical
|
||||||
* empty × light, empty × dark, populated × light, populated × dark
|
* [GalleryScreens] manifest):
|
||||||
*
|
*
|
||||||
* Per surface that's 4 PNGs × ~40 surfaces ≈ 160 goldens. Paired with the
|
* DataCarrying surfaces — capture 4 variants:
|
||||||
* iOS swift-snapshot-testing gallery (P3) that captures the same set of
|
* surface_empty_light.png (empty fixture, no lookups, light)
|
||||||
* (screen, data, theme) tuples, any visual divergence between the two
|
* surface_empty_dark.png (empty fixture, no lookups, dark)
|
||||||
* platforms surfaces here as a golden diff rather than silently shipping.
|
* surface_populated_light.png (populated fixture, light)
|
||||||
|
* surface_populated_dark.png (populated fixture, dark)
|
||||||
*
|
*
|
||||||
* How this differs from the showcase tests that lived here before:
|
* DataFree surfaces — capture 2 variants:
|
||||||
* - Showcases rendered hand-crafted theme-agnostic surfaces; now we
|
* surface_light.png (empty fixture, lookups seeded, light)
|
||||||
* render the actual production composables (`LoginScreen(…)`, etc.)
|
* surface_dark.png (empty fixture, lookups seeded, dark)
|
||||||
* through the fixture-backed [LocalDataManager].
|
|
||||||
* - Surfaces are declared in [GallerySurfaces.kt] instead of being
|
|
||||||
* inlined, so adding a new screen is a one-line change.
|
|
||||||
* - Previously 6 surfaces × 3 themes × 2 modes; now the matrix is
|
|
||||||
* N surfaces × {empty, populated} × {light, dark} — themes beyond
|
|
||||||
* the default are intentionally out of scope (theme variation is
|
|
||||||
* covered by the dedicated theme_selection surface).
|
|
||||||
*
|
*
|
||||||
* One parameterized test per surface gives granular CI failures — the
|
* The `empty` fixture for DataCarrying variants passes
|
||||||
|
* `seedLookups = false` so form dropdowns render their empty state
|
||||||
|
* (yielding a visible populated-vs-empty diff for forms that read
|
||||||
|
* lookups from `DataManager`). The `empty` fixture for DataFree
|
||||||
|
* variants passes `seedLookups = true` because those screens expect
|
||||||
|
* realistic production lookups even when the user has no entities yet.
|
||||||
|
*
|
||||||
|
* DataFree surfaces omit the populated variant entirely — the screens
|
||||||
|
* render no entity data, so `populated` would be byte-identical to
|
||||||
|
* `empty` and add zero signal.
|
||||||
|
*
|
||||||
|
* Granular CI failures: one parameterized test per surface means the
|
||||||
* report shows `ScreenshotTests[login]`, `ScreenshotTests[tasks]`, etc.
|
* report shows `ScreenshotTests[login]`, `ScreenshotTests[tasks]`, etc.
|
||||||
* rather than one monolithic failure when any surface drifts.
|
* rather than one monolithic failure when any surface drifts.
|
||||||
*
|
*
|
||||||
* Why the goldens land directly under `src/androidUnitTest/roborazzi/`:
|
* Why the goldens land directly under `src/androidUnitTest/roborazzi/`:
|
||||||
* Roborazzi resolves `captureRoboImage(filePath = …)` relative to the
|
* Roborazzi resolves `captureRoboImage(filePath = …)` relative to the
|
||||||
* Gradle test task's working directory (the module root). Writing to
|
* Gradle test task's working directory (the module root). Writing to
|
||||||
* the same directory where goldens are committed means record and verify
|
* the same directory where goldens are committed means record and
|
||||||
* round-trip through one canonical location; we never have to copy
|
* verify round-trip through one canonical location.
|
||||||
* between a transient `build/outputs/roborazzi/` and the committed
|
|
||||||
* fixture directory (which was the source of the pre-existing
|
|
||||||
* "original file was not found" failure).
|
|
||||||
*/
|
*/
|
||||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@@ -64,24 +70,15 @@ class ScreenshotTests(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose Multiplatform's `stringResource()` loads text via a
|
* Compose Multiplatform's `stringResource()` loads text via a
|
||||||
* JVM-static context held by `AndroidContextProvider`. In a real APK
|
* JVM-static context held by `AndroidContextProvider`. Under
|
||||||
* that ContentProvider is registered in the manifest and populated at
|
* Robolectric unit tests the `ContentProvider` that normally
|
||||||
* app start; under Robolectric unit tests it never runs, so every
|
* populates it never runs, so every `stringResource(...)` call throws
|
||||||
* `stringResource(...)` call throws "Android context is not
|
* "Android context is not initialized."
|
||||||
* initialized."
|
|
||||||
*
|
|
||||||
* `PreviewContextConfigurationEffect()` is the documented fix — but
|
|
||||||
* it only fires inside `LocalInspectionMode = true`, and even then
|
|
||||||
* the first composition frame renders before the effect lands, so
|
|
||||||
* `stringResource()` calls race the context set.
|
|
||||||
*
|
*
|
||||||
* Install the context eagerly via reflection before each test.
|
* Install the context eagerly via reflection before each test.
|
||||||
* `AndroidContextProvider` is `internal` in Kotlin, so we can't
|
* `AndroidContextProvider` is `internal`, but its static slot is
|
||||||
* touch its class directly — but its static slot is writable
|
* writable through the generated `Companion.setANDROID_CONTEXT`
|
||||||
* through the generated `Companion.setANDROID_CONTEXT` accessor.
|
* accessor.
|
||||||
* `@Before` runs inside the Robolectric sandbox (where
|
|
||||||
* `ApplicationProvider` is valid); `@BeforeClass` would run outside
|
|
||||||
* it and fail with "No instrumentation registered!".
|
|
||||||
*/
|
*/
|
||||||
@Before
|
@Before
|
||||||
fun bootstrapComposeResources() {
|
fun bootstrapComposeResources() {
|
||||||
@@ -95,14 +92,44 @@ class ScreenshotTests(
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun captureAllVariants() {
|
fun captureAllVariants() {
|
||||||
Variant.all().forEach { variant ->
|
val screen = GalleryScreens.forAndroid[surface.name]
|
||||||
val fileName = "${surface.name}_${variant.state}_${variant.mode}.png"
|
?: error(
|
||||||
|
"Surface '${surface.name}' is in GallerySurfaces.kt but not in " +
|
||||||
|
"GalleryScreens.all (canonical manifest). " +
|
||||||
|
"GalleryManifestParityTest should have caught this.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val variants = when (screen.category) {
|
||||||
|
GalleryCategory.DataCarrying -> Variant.dataCarrying
|
||||||
|
GalleryCategory.DataFree -> Variant.dataFree
|
||||||
|
}
|
||||||
|
|
||||||
|
variants.forEach { variant ->
|
||||||
|
val fileName = "${surface.name}${variant.fileSuffix}.png"
|
||||||
|
val fixture = variant.dataManager()
|
||||||
|
seedSingleton(fixture)
|
||||||
|
// Flush the main-thread Looper so any `stateIn(... Eagerly)`
|
||||||
|
// collectors on VMs reused across captures have processed the
|
||||||
|
// DataManager update before we snapshot. Without this, VMs
|
||||||
|
// might see the previous variant's data because coroutine
|
||||||
|
// emissions race the capture call.
|
||||||
|
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
|
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
|
||||||
HoneyDueTheme(
|
HoneyDueTheme(
|
||||||
themeColors = AppThemes.Default,
|
themeColors = AppThemes.Default,
|
||||||
darkTheme = variant.darkTheme,
|
darkTheme = variant.darkTheme,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalDataManager provides variant.dataManager()) {
|
// `LocalInspectionMode = true` signals to production
|
||||||
|
// composables that they're rendering in a hermetic
|
||||||
|
// preview/test environment. Camera pickers, gated push
|
||||||
|
// registrations, and animation callbacks use this flag
|
||||||
|
// to short-circuit calls that require real Android
|
||||||
|
// subsystems (e.g. `FileProvider` paths that aren't
|
||||||
|
// resolvable under Robolectric's test data dir).
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalDataManager provides fixture,
|
||||||
|
LocalInspectionMode provides true,
|
||||||
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
surface.content()
|
surface.content()
|
||||||
}
|
}
|
||||||
@@ -110,6 +137,47 @@ class ScreenshotTests(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Reset after suite so other tests don't inherit state.
|
||||||
|
com.tt.honeyDue.data.DataManager.setSubscription(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror every StateFlow on `fixture` onto the `DataManager` singleton
|
||||||
|
* so code paths that bypass `LocalDataManager` (screens that call
|
||||||
|
* `DataManager.x` directly, VMs whose default-arg resolves to the
|
||||||
|
* singleton, `SubscriptionHelper` free-tier gate) see the same data.
|
||||||
|
*
|
||||||
|
* Critical: clear the singleton first so the previous variant's
|
||||||
|
* writes don't leak into this variant's `empty` render.
|
||||||
|
*/
|
||||||
|
private fun seedSingleton(fixture: IDataManager) {
|
||||||
|
val dm = com.tt.honeyDue.data.DataManager
|
||||||
|
dm.clear()
|
||||||
|
dm.setSubscription(fixture.subscription.value)
|
||||||
|
dm.setCurrentUser(fixture.currentUser.value)
|
||||||
|
fixture.myResidences.value?.let { dm.setMyResidences(it) }
|
||||||
|
dm.setResidences(fixture.residences.value)
|
||||||
|
fixture.totalSummary.value?.let { dm.setTotalSummary(it) }
|
||||||
|
fixture.allTasks.value?.let { dm.setAllTasks(it) }
|
||||||
|
dm.setDocuments(fixture.documents.value)
|
||||||
|
dm.setContractors(fixture.contractors.value)
|
||||||
|
dm.setFeatureBenefits(fixture.featureBenefits.value)
|
||||||
|
dm.setUpgradeTriggers(fixture.upgradeTriggers.value)
|
||||||
|
dm.setTaskCategories(fixture.taskCategories.value)
|
||||||
|
dm.setTaskPriorities(fixture.taskPriorities.value)
|
||||||
|
dm.setTaskFrequencies(fixture.taskFrequencies.value)
|
||||||
|
fixture.contractorsByResidence.value.forEach { (rid, list) ->
|
||||||
|
dm.setContractorsForResidence(rid, list)
|
||||||
|
}
|
||||||
|
fixture.contractorDetail.value.values.forEach { dm.setContractorDetail(it) }
|
||||||
|
fixture.documentDetail.value.values.forEach { dm.setDocumentDetail(it) }
|
||||||
|
fixture.taskCompletions.value.forEach { (taskId, completions) ->
|
||||||
|
dm.setTaskCompletions(taskId, completions)
|
||||||
|
}
|
||||||
|
fixture.tasksByResidence.value.forEach { (rid, cols) ->
|
||||||
|
dm.setTasksForResidence(rid, cols)
|
||||||
|
}
|
||||||
|
fixture.notificationPreferences.value?.let { dm.setNotificationPreferences(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -121,23 +189,48 @@ class ScreenshotTests(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One of the four render-variants captured per surface. The
|
* One render-variant captured per surface. The `dataManager` factory is
|
||||||
* `dataManager` factory is invoked lazily so each capture gets its own
|
* invoked lazily so each capture gets a pristine fixture (avoiding
|
||||||
* pristine fixture (avoiding cross-test StateFlow mutation).
|
* cross-test StateFlow mutation).
|
||||||
|
*
|
||||||
|
* @property fileSuffix Appended to the surface name to form the PNG
|
||||||
|
* filename. Includes a leading `_`. Examples: `_empty_light`,
|
||||||
|
* `_populated_dark`, `_light`, `_dark`.
|
||||||
*/
|
*/
|
||||||
private data class Variant(
|
private data class Variant(
|
||||||
val state: String,
|
val fileSuffix: String,
|
||||||
val mode: String,
|
|
||||||
val darkTheme: Boolean,
|
val darkTheme: Boolean,
|
||||||
val dataManager: () -> IDataManager,
|
val dataManager: () -> IDataManager,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun all(): List<Variant> = listOf(
|
/**
|
||||||
Variant("empty", "light", darkTheme = false) { FixtureDataManager.empty() },
|
* DataCarrying surfaces: 4 variants. `empty` captures pass
|
||||||
Variant("empty", "dark", darkTheme = true) { FixtureDataManager.empty() },
|
* `seedLookups = false` so form dropdowns render empty in the
|
||||||
Variant("populated", "light", darkTheme = false) { FixtureDataManager.populated() },
|
* empty-variant PNGs — letting screens that read lookups produce
|
||||||
Variant("populated", "dark", darkTheme = true) { FixtureDataManager.populated() },
|
* a visible diff against the populated variant.
|
||||||
|
*/
|
||||||
|
val dataCarrying: List<Variant> = listOf(
|
||||||
|
Variant("_empty_light", darkTheme = false) {
|
||||||
|
FixtureDataManager.empty(seedLookups = false)
|
||||||
|
},
|
||||||
|
Variant("_empty_dark", darkTheme = true) {
|
||||||
|
FixtureDataManager.empty(seedLookups = false)
|
||||||
|
},
|
||||||
|
Variant("_populated_light", darkTheme = false) { FixtureDataManager.populated() },
|
||||||
|
Variant("_populated_dark", darkTheme = true) { FixtureDataManager.populated() },
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataFree surfaces: 2 variants (light/dark only). Lookups are
|
||||||
|
* seeded because forms expect them to be present in production
|
||||||
|
* (a user with zero entities still sees the priority picker).
|
||||||
|
* The populated variant is deliberately omitted — DataFree
|
||||||
|
* surfaces render no entity data, so `populated` would be
|
||||||
|
* byte-identical to `empty`.
|
||||||
|
*/
|
||||||
|
val dataFree: List<Variant> = listOf(
|
||||||
|
Variant("_light", darkTheme = false) { FixtureDataManager.empty(seedLookups = true) },
|
||||||
|
Variant("_dark", darkTheme = true) { FixtureDataManager.empty(seedLookups = true) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
composeApp/src/androidUnitTest/roborazzi/add_document_dark.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/add_document_light.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 60 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/biometric_lock_dark.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 62 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/complete_task_dark.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/complete_task_light.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 54 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/edit_document_dark.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/edit_document_light.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 72 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/edit_task_dark.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/edit_task_light.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/login_dark.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 41 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/login_light.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 31 KiB |